Skip to main content

raps_oss/
types.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Type definitions for the OSS API module.
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9use std::str::FromStr;
10
11/// Bucket retention policy
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "camelCase")]
14pub enum RetentionPolicy {
15    /// Files are automatically deleted after 24 hours
16    Transient,
17    /// Files are automatically deleted after 30 days
18    Temporary,
19    /// Files are kept until explicitly deleted
20    Persistent,
21}
22
23impl std::fmt::Display for RetentionPolicy {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            RetentionPolicy::Transient => write!(f, "transient"),
27            RetentionPolicy::Temporary => write!(f, "temporary"),
28            RetentionPolicy::Persistent => write!(f, "persistent"),
29        }
30    }
31}
32
33impl RetentionPolicy {
34    pub fn all() -> Vec<Self> {
35        vec![Self::Transient, Self::Temporary, Self::Persistent]
36    }
37}
38
39impl FromStr for RetentionPolicy {
40    type Err = String;
41
42    fn from_str(s: &str) -> Result<Self, Self::Err> {
43        match s.to_lowercase().as_str() {
44            "transient" => Ok(Self::Transient),
45            "temporary" => Ok(Self::Temporary),
46            "persistent" => Ok(Self::Persistent),
47            _ => Err("Invalid retention policy".to_string()),
48        }
49    }
50}
51
52/// Region for bucket storage
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54pub enum Region {
55    US,
56    #[allow(clippy::upper_case_acronyms)]
57    EMEA,
58}
59
60impl std::fmt::Display for Region {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match self {
63            Region::US => write!(f, "US"),
64            Region::EMEA => write!(f, "EMEA"),
65        }
66    }
67}
68
69impl Region {
70    pub fn all() -> Vec<Self> {
71        vec![Self::US, Self::EMEA]
72    }
73}
74
75/// Request to create a new bucket
76#[derive(Debug, Serialize)]
77#[serde(rename_all = "camelCase")]
78pub struct CreateBucketRequest {
79    pub bucket_key: String,
80    pub policy_key: String,
81}
82
83/// Bucket information returned from API
84#[derive(Debug, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct Bucket {
87    pub bucket_key: String,
88    pub bucket_owner: String,
89    pub created_date: u64,
90    pub permissions: Vec<Permission>,
91    pub policy_key: String,
92}
93
94/// Permission information for a bucket
95#[derive(Debug, Deserialize)]
96#[serde(rename_all = "camelCase")]
97pub struct Permission {
98    pub auth_id: String,
99    pub access: String,
100}
101
102/// Response when listing buckets
103#[derive(Debug, Deserialize)]
104pub struct BucketsResponse {
105    pub items: Vec<BucketItem>,
106    pub next: Option<String>,
107}
108
109/// Bucket item in list response
110#[derive(Debug, Clone, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct BucketItem {
113    pub bucket_key: String,
114    pub created_date: u64,
115    pub policy_key: String,
116    /// Region where the bucket is stored (added by client, not from API)
117    #[serde(skip)]
118    pub region: Option<String>,
119}
120
121/// Result from a single region query (used by streaming bucket listing).
122#[derive(Debug)]
123pub struct RegionResult {
124    pub region: Region,
125    pub buckets: anyhow::Result<Vec<BucketItem>>,
126    pub elapsed: std::time::Duration,
127}
128
129/// Signed S3 download response
130#[derive(Debug, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct SignedS3DownloadResponse {
133    /// Pre-signed S3 URL for direct download
134    pub url: Option<String>,
135    /// Multiple URLs if object was uploaded in chunks
136    pub urls: Option<Vec<String>>,
137    /// Object size in bytes
138    pub size: Option<u64>,
139    /// SHA-1 hash
140    pub sha1: Option<String>,
141    /// Status of the object
142    pub status: Option<String>,
143}
144
145/// Signed S3 upload response
146#[derive(Debug, Clone, Deserialize, Serialize)]
147#[serde(rename_all = "camelCase")]
148pub struct SignedS3UploadResponse {
149    /// Upload key to use for completion
150    pub upload_key: String,
151    /// Pre-signed S3 URLs for upload
152    pub urls: Vec<String>,
153    /// Expiration timestamp
154    pub upload_expiration: Option<String>,
155}
156
157/// Multipart upload state for resume capability
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct MultipartUploadState {
160    /// Bucket key
161    pub bucket_key: String,
162    /// Object key
163    pub object_key: String,
164    /// Local file path
165    pub file_path: String,
166    /// Total file size
167    pub file_size: u64,
168    /// Chunk size used
169    pub chunk_size: u64,
170    /// Total number of parts
171    pub total_parts: u32,
172    /// Completed part numbers (1-indexed)
173    pub completed_parts: Vec<u32>,
174    /// ETags for completed parts (part_number -> etag)
175    pub part_etags: std::collections::HashMap<u32, String>,
176    /// Upload key from signed URL request
177    pub upload_key: String,
178    /// Timestamp when upload started
179    pub started_at: i64,
180    /// File modification time for validation
181    pub file_mtime: i64,
182}
183
184impl MultipartUploadState {
185    /// Default chunk size: 5MB (minimum for S3 multipart)
186    pub const DEFAULT_CHUNK_SIZE: u64 = 5 * 1024 * 1024;
187    /// Maximum chunk size: 100MB
188    pub const MAX_CHUNK_SIZE: u64 = 100 * 1024 * 1024;
189    /// Threshold for multipart upload: 5MB
190    pub const MULTIPART_THRESHOLD: u64 = 5 * 1024 * 1024;
191
192    /// Get the state file path for a given upload
193    pub fn state_file_path(bucket_key: &str, object_key: &str) -> Result<PathBuf> {
194        let proj_dirs = directories::ProjectDirs::from("com", "autodesk", "raps")
195            .context("Failed to get project directories")?;
196        let cache_dir = proj_dirs.cache_dir();
197        std::fs::create_dir_all(cache_dir)?;
198
199        // Create a safe filename from bucket and object key
200        let safe_name = format!("{}_{}", bucket_key, object_key)
201            .chars()
202            .map(|c| {
203                if c.is_alphanumeric() || c == '-' || c == '_' {
204                    c
205                } else {
206                    '_'
207                }
208            })
209            .collect::<String>();
210
211        Ok(cache_dir.join(format!("upload_{}.json", safe_name)))
212    }
213
214    /// Save state to file
215    pub fn save(&self) -> Result<()> {
216        let path = Self::state_file_path(&self.bucket_key, &self.object_key)?;
217        let json = serde_json::to_string_pretty(self)?;
218        std::fs::write(&path, json)?;
219        Ok(())
220    }
221
222    /// Load state from file
223    pub fn load(bucket_key: &str, object_key: &str) -> Result<Option<Self>> {
224        let path = Self::state_file_path(bucket_key, object_key)?;
225        if !path.exists() {
226            return Ok(None);
227        }
228        let json = std::fs::read_to_string(&path)?;
229        let state: Self = serde_json::from_str(&json)?;
230        Ok(Some(state))
231    }
232
233    /// Delete state file
234    pub fn delete(bucket_key: &str, object_key: &str) -> Result<()> {
235        let path = Self::state_file_path(bucket_key, object_key)?;
236        if path.exists() {
237            std::fs::remove_file(&path)?;
238        }
239        Ok(())
240    }
241
242    /// Check if the upload can be resumed (file hasn't changed)
243    pub fn can_resume(&self, file_path: &Path) -> bool {
244        if let Ok(metadata) = std::fs::metadata(file_path) {
245            let current_size = metadata.len();
246            let current_mtime = metadata
247                .modified()
248                .ok()
249                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
250                .map(|d| d.as_secs() as i64)
251                .unwrap_or(0);
252
253            current_size == self.file_size && current_mtime == self.file_mtime
254        } else {
255            false
256        }
257    }
258
259    /// Calculate which parts still need to be uploaded
260    pub fn remaining_parts(&self) -> Vec<u32> {
261        (1..=self.total_parts)
262            .filter(|p| !self.completed_parts.contains(p))
263            .collect()
264    }
265}
266
267/// Object information
268#[derive(Debug, Deserialize)]
269#[serde(rename_all = "camelCase")]
270pub struct ObjectInfo {
271    pub bucket_key: String,
272    pub object_key: String,
273    pub object_id: String,
274    #[serde(default)]
275    pub sha1: Option<String>,
276    pub size: u64,
277    #[serde(default)]
278    pub location: Option<String>,
279    /// Content type (may be returned by some endpoints)
280    #[serde(default)]
281    pub content_type: Option<String>,
282}
283
284/// Extended object metadata returned by object details endpoint
285#[derive(Debug, Clone, Deserialize)]
286#[serde(rename_all = "camelCase")]
287pub struct ObjectDetails {
288    /// Bucket key
289    pub bucket_key: String,
290    /// Object key (filename)
291    pub object_key: String,
292    /// Object ID (URN format)
293    pub object_id: String,
294    /// SHA-1 hash of the object
295    pub sha1: String,
296    /// Object size in bytes
297    pub size: u64,
298    /// MIME content type
299    pub content_type: String,
300    /// Content disposition header value
301    #[serde(default)]
302    pub content_disposition: Option<String>,
303    /// Creation timestamp (ISO 8601)
304    #[serde(alias = "createdDate")]
305    pub created_date: Option<String>,
306    /// Last modified timestamp (ISO 8601)
307    #[serde(alias = "lastModifiedDate")]
308    pub last_modified_date: Option<String>,
309    /// Location URL
310    #[serde(default)]
311    pub location: Option<String>,
312}
313
314/// Response when listing objects
315#[derive(Debug, Deserialize)]
316pub struct ObjectsResponse {
317    pub items: Vec<ObjectItem>,
318    pub next: Option<String>,
319}
320
321/// Object item in list response
322#[derive(Debug, Deserialize)]
323#[serde(rename_all = "camelCase")]
324pub struct ObjectItem {
325    pub bucket_key: String,
326    pub object_key: String,
327    pub object_id: String,
328    #[serde(default)]
329    pub sha1: Option<String>,
330    pub size: u64,
331}
332
333// ============== BATCH OPERATION TYPES ==============
334
335/// Result of a batch operation with per-item tracking
336#[derive(Debug, Serialize)]
337pub struct BatchResult<T: std::fmt::Debug> {
338    pub total: usize,
339    pub succeeded: usize,
340    pub failed: usize,
341    pub results: Vec<BatchItemResult<T>>,
342}
343
344/// Result of a single item within a batch operation
345#[derive(Debug, Serialize)]
346pub struct BatchItemResult<T: std::fmt::Debug> {
347    pub key: String,
348    #[serde(skip)]
349    pub result: std::result::Result<T, String>,
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn test_multipart_upload_state_constants() {
358        assert_eq!(MultipartUploadState::DEFAULT_CHUNK_SIZE, 5 * 1024 * 1024);
359        assert_eq!(MultipartUploadState::MAX_CHUNK_SIZE, 100 * 1024 * 1024);
360        assert_eq!(MultipartUploadState::MULTIPART_THRESHOLD, 5 * 1024 * 1024);
361    }
362
363    #[test]
364    fn test_multipart_upload_state_remaining_parts() {
365        let state = MultipartUploadState {
366            bucket_key: "test-bucket".to_string(),
367            object_key: "test-object".to_string(),
368            file_path: "/tmp/test.bin".to_string(),
369            file_size: 20 * 1024 * 1024,
370            chunk_size: 5 * 1024 * 1024,
371            total_parts: 4,
372            completed_parts: vec![1, 3],
373            part_etags: std::collections::HashMap::new(),
374            upload_key: "test-key".to_string(),
375            started_at: 0,
376            file_mtime: 0,
377        };
378
379        let remaining = state.remaining_parts();
380        assert_eq!(remaining, vec![2, 4]);
381    }
382
383    #[test]
384    fn test_retention_policy_display() {
385        assert_eq!(RetentionPolicy::Transient.to_string(), "transient");
386        assert_eq!(RetentionPolicy::Temporary.to_string(), "temporary");
387        assert_eq!(RetentionPolicy::Persistent.to_string(), "persistent");
388    }
389
390    #[test]
391    fn test_retention_policy_from_str() {
392        assert_eq!(
393            RetentionPolicy::from_str("transient"),
394            Ok(RetentionPolicy::Transient)
395        );
396        assert_eq!(
397            RetentionPolicy::from_str("TRANSIENT"),
398            Ok(RetentionPolicy::Transient)
399        );
400        assert!(RetentionPolicy::from_str("invalid").is_err());
401    }
402
403    #[test]
404    fn test_region_display() {
405        assert_eq!(Region::US.to_string(), "US");
406        assert_eq!(Region::EMEA.to_string(), "EMEA");
407    }
408
409    #[test]
410    fn test_region_all() {
411        let regions = Region::all();
412        assert_eq!(regions.len(), 2);
413        assert!(regions.contains(&Region::US));
414        assert!(regions.contains(&Region::EMEA));
415    }
416
417    #[test]
418    fn test_retention_policy_all() {
419        let policies = RetentionPolicy::all();
420        assert_eq!(policies.len(), 3);
421        assert!(policies.contains(&RetentionPolicy::Transient));
422        assert!(policies.contains(&RetentionPolicy::Temporary));
423        assert!(policies.contains(&RetentionPolicy::Persistent));
424    }
425
426    #[test]
427    fn test_retention_policy_temporary() {
428        assert_eq!(
429            RetentionPolicy::from_str("temporary"),
430            Ok(RetentionPolicy::Temporary)
431        );
432        assert_eq!(
433            RetentionPolicy::from_str("TEMPORARY"),
434            Ok(RetentionPolicy::Temporary)
435        );
436    }
437
438    #[test]
439    fn test_retention_policy_persistent() {
440        assert_eq!(
441            RetentionPolicy::from_str("persistent"),
442            Ok(RetentionPolicy::Persistent)
443        );
444        assert_eq!(
445            RetentionPolicy::from_str("PERSISTENT"),
446            Ok(RetentionPolicy::Persistent)
447        );
448    }
449
450    #[test]
451    fn test_multipart_upload_state_chunk_calculation() {
452        // File of 12 MB with 5 MB chunks = 3 parts
453        let file_size: u64 = 12 * 1024 * 1024;
454        let chunk_size = MultipartUploadState::DEFAULT_CHUNK_SIZE;
455        let total_parts = file_size.div_ceil(chunk_size);
456        assert_eq!(total_parts, 3);
457    }
458
459    #[test]
460    fn test_multipart_upload_state_all_parts_remaining() {
461        let state = MultipartUploadState {
462            bucket_key: "test-bucket".to_string(),
463            object_key: "test-object".to_string(),
464            file_path: "/tmp/test.bin".to_string(),
465            file_size: 15 * 1024 * 1024,
466            chunk_size: 5 * 1024 * 1024,
467            total_parts: 3,
468            completed_parts: vec![], // No parts completed
469            part_etags: std::collections::HashMap::new(),
470            upload_key: "test-key".to_string(),
471            started_at: 0,
472            file_mtime: 0,
473        };
474
475        let remaining = state.remaining_parts();
476        assert_eq!(remaining, vec![1, 2, 3]);
477    }
478
479    #[test]
480    fn test_multipart_upload_state_no_parts_remaining() {
481        let state = MultipartUploadState {
482            bucket_key: "test-bucket".to_string(),
483            object_key: "test-object".to_string(),
484            file_path: "/tmp/test.bin".to_string(),
485            file_size: 15 * 1024 * 1024,
486            chunk_size: 5 * 1024 * 1024,
487            total_parts: 3,
488            completed_parts: vec![1, 2, 3], // All parts completed
489            part_etags: std::collections::HashMap::new(),
490            upload_key: "test-key".to_string(),
491            started_at: 0,
492            file_mtime: 0,
493        };
494
495        let remaining = state.remaining_parts();
496        assert!(remaining.is_empty());
497    }
498
499    #[test]
500    fn test_create_bucket_request_serialization() {
501        let request = CreateBucketRequest {
502            bucket_key: "test-bucket".to_string(),
503            policy_key: "transient".to_string(),
504        };
505
506        let json = serde_json::to_value(&request).unwrap();
507        assert_eq!(json["bucketKey"], "test-bucket");
508        assert_eq!(json["policyKey"], "transient");
509    }
510
511    #[test]
512    fn test_bucket_deserialization() {
513        let json = r#"{
514            "bucketKey": "test-bucket",
515            "bucketOwner": "test-owner",
516            "createdDate": 1609459200000,
517            "permissions": [{"authId": "test-auth", "access": "full"}],
518            "policyKey": "transient"
519        }"#;
520
521        let bucket: Bucket = serde_json::from_str(json).unwrap();
522        assert_eq!(bucket.bucket_key, "test-bucket");
523        assert_eq!(bucket.bucket_owner, "test-owner");
524        assert_eq!(bucket.policy_key, "transient");
525        assert_eq!(bucket.permissions.len(), 1);
526    }
527
528    #[test]
529    fn test_buckets_response_deserialization() {
530        let json = r#"{
531            "items": [
532                {"bucketKey": "bucket1", "createdDate": 1609459200000, "policyKey": "transient"},
533                {"bucketKey": "bucket2", "createdDate": 1609459200000, "policyKey": "persistent"}
534            ],
535            "next": "bucket3"
536        }"#;
537
538        let response: BucketsResponse = serde_json::from_str(json).unwrap();
539        assert_eq!(response.items.len(), 2);
540        assert_eq!(response.items[0].bucket_key, "bucket1");
541        assert_eq!(response.next, Some("bucket3".to_string()));
542    }
543
544    #[test]
545    fn test_buckets_response_no_next() {
546        let json = r#"{
547            "items": [
548                {"bucketKey": "bucket1", "createdDate": 1609459200000, "policyKey": "transient"}
549            ]
550        }"#;
551
552        let response: BucketsResponse = serde_json::from_str(json).unwrap();
553        assert_eq!(response.items.len(), 1);
554        assert!(response.next.is_none());
555    }
556
557    #[test]
558    fn test_object_info_deserialization() {
559        let json = r#"{
560            "bucketKey": "test-bucket",
561            "objectKey": "test-object.dwg",
562            "objectId": "urn:adsk.objects:os.object:test-bucket/test-object.dwg",
563            "sha1": "abc123",
564            "size": 1024,
565            "location": "https://example.com/object"
566        }"#;
567
568        let object: ObjectInfo = serde_json::from_str(json).unwrap();
569        assert_eq!(object.bucket_key, "test-bucket");
570        assert_eq!(object.object_key, "test-object.dwg");
571        assert_eq!(object.size, 1024);
572    }
573
574    #[test]
575    fn test_objects_response_deserialization() {
576        let json = r#"{
577            "items": [
578                {"bucketKey": "bucket", "objectKey": "file1.dwg", "objectId": "urn:1", "size": 100},
579                {"bucketKey": "bucket", "objectKey": "file2.rvt", "objectId": "urn:2", "size": 200}
580            ],
581            "next": "file3.dwg"
582        }"#;
583
584        let response: ObjectsResponse = serde_json::from_str(json).unwrap();
585        assert_eq!(response.items.len(), 2);
586        assert_eq!(response.items[0].object_key, "file1.dwg");
587        assert_eq!(response.items[1].size, 200);
588    }
589
590    #[test]
591    fn test_signed_s3_download_response_deserialization() {
592        let json = r#"{
593            "url": "https://s3.amazonaws.com/signed-url",
594            "size": 1048576,
595            "sha1": "abc123"
596        }"#;
597
598        let response: SignedS3DownloadResponse = serde_json::from_str(json).unwrap();
599        assert_eq!(
600            response.url,
601            Some("https://s3.amazonaws.com/signed-url".to_string())
602        );
603        assert_eq!(response.size, Some(1048576));
604    }
605
606    #[test]
607    fn test_signed_s3_upload_response_deserialization() {
608        let json = r#"{
609            "uploadKey": "upload-key-123",
610            "urls": ["https://s3.amazonaws.com/part1", "https://s3.amazonaws.com/part2"],
611            "uploadExpiration": "2024-01-15T12:00:00Z"
612        }"#;
613
614        let response: SignedS3UploadResponse = serde_json::from_str(json).unwrap();
615        assert_eq!(response.upload_key, "upload-key-123");
616        assert_eq!(response.urls.len(), 2);
617    }
618
619    #[test]
620    fn test_retention_policy_serialization() {
621        let policy = RetentionPolicy::Persistent;
622        let json = serde_json::to_value(policy).unwrap();
623        assert_eq!(json, "persistent");
624    }
625
626    #[test]
627    fn test_region_serialization() {
628        let region = Region::EMEA;
629        let json = serde_json::to_value(region).unwrap();
630        assert_eq!(json, "EMEA");
631    }
632
633    #[test]
634    fn test_batch_result_summary() {
635        let result: BatchResult<ObjectDetails> = BatchResult {
636            total: 3,
637            succeeded: 2,
638            failed: 1,
639            results: vec![],
640        };
641        assert_eq!(result.total, 3);
642        assert_eq!(result.succeeded, 2);
643        assert_eq!(result.failed, 1);
644    }
645
646    #[test]
647    fn test_batch_item_result_success_and_failure() {
648        let success: BatchItemResult<String> = BatchItemResult {
649            key: "file.txt".to_string(),
650            result: Ok("done".to_string()),
651        };
652        assert!(success.result.is_ok());
653
654        let failure: BatchItemResult<String> = BatchItemResult {
655            key: "missing.txt".to_string(),
656            result: Err("not found".to_string()),
657        };
658        assert!(failure.result.is_err());
659        assert_eq!(failure.result.unwrap_err(), "not found");
660    }
661}