fundamentum_sdk_mqtt/models/actions/
file_transfer_action.rs

1use std::path::PathBuf;
2
3use crate::error::Error;
4use fundamentum_iot_mqtt_proto::com::fundamentum::actions::v1::{
5    self as cloud_proto, file_transfer_action::TransferType,
6};
7use url::Url;
8
9/// Action to transfer one file to and from the Cloud.
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub enum FileTransferAction {
12    /// Download type
13    Download(CloudToDeviceRequest),
14    /// Upload type
15    Upload(DeviceToCloudRequest),
16}
17
18/// Download a file from the Cloud to a gateway or a subsdevice.
19#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20pub struct CloudToDeviceRequest {
21    /// Absolute path requested by the user.
22    pub file_path: PathBuf,
23    /// File URL
24    pub url: Url,
25    /// File size (bytes)
26    pub size: u64,
27    /// File SRI (see <https://www.w3.org/TR/sri>) (preferred is sha256)
28    ///
29    /// Example : sha384-MBO5IDfYaE6c6Aao94oZrIOiC6CGiSN2n4QUbHNPhzk5Xhm0djZLQqTpL0HzTUxk
30    pub hash: String,
31    /// Allow the gRPC client to overwrite an already existing file.
32    pub overwrite: bool,
33    /// Any other metadata related to the file transfer. Metadata may be encoded
34    /// with JSON, base64 or with application-defined logic.
35    pub metadata: Option<String>,
36}
37
38/// Upload one file from one gateway or sub-device to the Cloud.
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40pub struct DeviceToCloudRequest {
41    /// Absolute path to the file.
42    pub file_path: PathBuf,
43    /// File URL
44    pub url: Url,
45}
46
47impl TryFrom<cloud_proto::FileTransferAction> for FileTransferAction {
48    type Error = Error;
49
50    fn try_from(value: cloud_proto::FileTransferAction) -> Result<Self, Self::Error> {
51        match value.transfer_type {
52            Some(TransferType::Download(download)) => {
53                let url =
54                    Url::parse(&download.url).map_err(|e| Error::InvalidRequest(e.to_string()))?;
55                if url.scheme() != "https" {
56                    return Err(Error::InvalidRequest(
57                        "URL must be with a HTTPS scheme".to_owned(),
58                    ));
59                }
60
61                if download.file_path.is_empty() {
62                    return Err(Error::InvalidRequest("Path cannot be empty".to_owned()));
63                }
64                if download.size == 0 {
65                    return Err(Error::InvalidRequest("File size cannot be 0".to_owned()));
66                }
67                if download.hash.is_empty() {
68                    return Err(Error::InvalidRequest("Hash cannot be empty".to_owned()));
69                }
70
71                Ok(Self::Download(CloudToDeviceRequest {
72                    file_path: PathBuf::from(download.file_path),
73                    url,
74                    size: download.size,
75                    hash: download.hash,
76                    overwrite: download.overwrite,
77                    metadata: (!download.metadata.is_empty()).then_some(download.metadata),
78                }))
79            }
80
81            Some(TransferType::Upload(upload)) => {
82                let url =
83                    Url::parse(&upload.url).map_err(|e| Error::InvalidRequest(e.to_string()))?;
84                if url.scheme() != "https" {
85                    return Err(Error::InvalidRequest(
86                        "URL must be with a HTTPS scheme".to_owned(),
87                    ));
88                }
89
90                if upload.file_path.is_empty() {
91                    return Err(Error::InvalidRequest("Path cannot be empty".to_owned()));
92                }
93
94                Ok(Self::Upload(DeviceToCloudRequest {
95                    file_path: PathBuf::from(upload.file_path),
96                    url,
97                }))
98            }
99            None => Err(Error::InvalidRequest(
100                "There is no transfer type in the FileTransferAction".to_owned(),
101            )),
102        }
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    use fundamentum_iot_mqtt_proto::com::fundamentum::actions::v1 as cloud_proto;
111
112    const ANY_FILE_PATH: &str = "/any/path";
113    const ANY_URL: &str = "https://aws.amazon.com/file?a=42&b=42";
114    const ANY_SIZE: u64 = 42;
115    const ANY_HASH: &str =
116        "sha384-MBO5IDfYaE6c6Aao94oZrIOiC6CGiSN2n4QUbHNPhzk5Xhm0djZLQqTpL0HzTUxk";
117    const ANY_METADATA: &str = "metadata";
118
119    #[test]
120    fn given_no_transfer_type_when_try_into_then_error() {
121        let incoming_transfer = cloud_proto::FileTransferAction {
122            transfer_type: None,
123        };
124
125        let result: Result<FileTransferAction, Error> = incoming_transfer.try_into();
126
127        assert!(result.is_err());
128    }
129
130    // //////////////////////////////////////// MARK: Download
131
132    fn given_incoming_download(
133        request: cloud_proto::file_transfer_action::CloudToDeviceRequest,
134    ) -> cloud_proto::FileTransferAction {
135        cloud_proto::FileTransferAction {
136            transfer_type: Some(TransferType::Download(request)),
137        }
138    }
139
140    fn new_cloud_to_device_request() -> cloud_proto::file_transfer_action::CloudToDeviceRequest {
141        cloud_proto::file_transfer_action::CloudToDeviceRequest {
142            file_path: ANY_FILE_PATH.to_owned(),
143            url: ANY_URL.to_owned(),
144            size: ANY_SIZE,
145            hash: ANY_HASH.to_owned(),
146            overwrite: false,
147            metadata: ANY_METADATA.to_owned(),
148        }
149    }
150
151    #[test]
152    fn given_download_file_transfer_action_when_try_into_then_valid_transfer_action() {
153        let incoming_transfer = given_incoming_download(new_cloud_to_device_request());
154
155        let download: FileTransferAction =
156            incoming_transfer.try_into().expect("All fields are valid");
157
158        assert_eq!(
159            download,
160            FileTransferAction::Download(CloudToDeviceRequest {
161                file_path: PathBuf::from(ANY_FILE_PATH),
162                url: Url::parse(ANY_URL).expect("ANY_URL must be valid"),
163                size: ANY_SIZE,
164                hash: ANY_HASH.to_owned(),
165                overwrite: false,
166                metadata: Some(ANY_METADATA.to_owned())
167            })
168        );
169    }
170
171    #[test]
172    fn given_empty_path_when_try_into_then_error() {
173        let incoming_transfer =
174            given_incoming_download(cloud_proto::file_transfer_action::CloudToDeviceRequest {
175                file_path: String::new(),
176                ..new_cloud_to_device_request()
177            });
178
179        let result: Result<FileTransferAction, Error> = incoming_transfer.try_into();
180
181        assert!(result.is_err());
182    }
183
184    #[test]
185    fn given_non_https_url_when_try_into_then_error() {
186        let incoming_transfer =
187            given_incoming_download(cloud_proto::file_transfer_action::CloudToDeviceRequest {
188                url: "mqtts://aws.amazon.com".to_owned(),
189                ..new_cloud_to_device_request()
190            });
191
192        let result: Result<FileTransferAction, Error> = incoming_transfer.try_into();
193
194        assert!(result.is_err());
195    }
196
197    #[test]
198    fn given_empty_file_when_try_into_then_error() {
199        let incoming_transfer =
200            given_incoming_download(cloud_proto::file_transfer_action::CloudToDeviceRequest {
201                size: 0,
202                ..new_cloud_to_device_request()
203            });
204
205        let result: Result<FileTransferAction, Error> = incoming_transfer.try_into();
206
207        assert!(result.is_err());
208    }
209
210    #[test]
211    fn given_empty_hash_when_try_into_then_error() {
212        let incoming_transfer =
213            given_incoming_download(cloud_proto::file_transfer_action::CloudToDeviceRequest {
214                hash: String::new(),
215                ..new_cloud_to_device_request()
216            });
217
218        let result: Result<FileTransferAction, Error> = incoming_transfer.try_into();
219
220        assert!(result.is_err());
221    }
222
223    #[test]
224    fn given_empty_metadata_when_try_into_then_no_metadata() {
225        let incoming_transfer =
226            given_incoming_download(cloud_proto::file_transfer_action::CloudToDeviceRequest {
227                metadata: String::new(),
228                ..new_cloud_to_device_request()
229            });
230
231        let action: FileTransferAction =
232            incoming_transfer.try_into().expect("All fields are valid");
233
234        let is_none = match action {
235            FileTransferAction::Download(cloud_to_device_request) => {
236                cloud_to_device_request.metadata.is_none()
237            }
238            FileTransferAction::Upload(_) => false,
239        };
240        assert!(is_none);
241    }
242
243    // //////////////////////////////////////// MARK: Upload
244
245    #[test]
246    fn given_upload_file_transfer_action_when_try_into_then_valid_transfer_action() {
247        let incoming_transfer = cloud_proto::FileTransferAction {
248            transfer_type: Some(TransferType::Upload(
249                cloud_proto::file_transfer_action::DeviceToCloudRequest {
250                    file_path: ANY_FILE_PATH.to_owned(),
251                    url: ANY_URL.to_owned(),
252                },
253            )),
254        };
255
256        let upload: FileTransferAction =
257            incoming_transfer.try_into().expect("All fields are valid");
258
259        assert_eq!(
260            upload,
261            FileTransferAction::Upload(DeviceToCloudRequest {
262                file_path: PathBuf::from(ANY_FILE_PATH),
263                url: Url::parse(ANY_URL).expect("ANY_URL must be valid"),
264            })
265        );
266    }
267
268    #[test]
269    fn given_empty_path2_when_try_into_then_error() {
270        let incoming_transfer = cloud_proto::FileTransferAction {
271            transfer_type: Some(TransferType::Upload(
272                cloud_proto::file_transfer_action::DeviceToCloudRequest {
273                    file_path: String::new(),
274                    url: ANY_URL.to_owned(),
275                },
276            )),
277        };
278
279        let result: Result<FileTransferAction, Error> = incoming_transfer.try_into();
280
281        assert!(result.is_err());
282    }
283
284    #[test]
285    fn given_non_https_url2_when_try_into_then_error() {
286        let incoming_transfer = cloud_proto::FileTransferAction {
287            transfer_type: Some(TransferType::Upload(
288                cloud_proto::file_transfer_action::DeviceToCloudRequest {
289                    file_path: ANY_FILE_PATH.to_owned(),
290                    url: "mqtts://aws.amazon.com".to_owned(),
291                },
292            )),
293        };
294
295        let result: Result<FileTransferAction, Error> = incoming_transfer.try_into();
296
297        assert!(result.is_err());
298    }
299}