Skip to main content

alien_core/
presigned.rs

1use crate::error::{ErrorData, Result};
2use alien_error::{AlienError, Context, IntoAlienError};
3use bytes::Bytes;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8#[cfg(feature = "openapi")]
9use utoipa::ToSchema;
10
11/// A presigned request that can be serialized, stored, and executed later.
12/// Hides implementation details for different storage backends.
13#[derive(Eq, PartialEq, Debug, Clone, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15#[cfg_attr(feature = "openapi", derive(ToSchema))]
16pub struct PresignedRequest {
17    /// The storage backend this request targets
18    pub backend: PresignedRequestBackend,
19    /// When this presigned request expires
20    pub expiration: DateTime<Utc>,
21    /// The operation this request performs
22    pub operation: PresignedOperation,
23    /// The path this request operates on
24    pub path: String,
25}
26
27/// Storage backend representation for different presigned request types
28#[derive(Eq, PartialEq, Debug, Clone, Serialize, Deserialize)]
29#[serde(tag = "type", rename_all = "camelCase")]
30#[cfg_attr(feature = "openapi", derive(ToSchema))]
31pub enum PresignedRequestBackend {
32    /// HTTP-based request (AWS S3, GCP GCS, Azure Blob)
33    #[serde(rename_all = "camelCase")]
34    Http {
35        url: String,
36        method: String,
37        headers: HashMap<String, String>,
38    },
39    /// Local filesystem operation
40    #[serde(rename_all = "camelCase")]
41    Local {
42        file_path: String,
43        operation: LocalOperation,
44    },
45}
46
47/// The type of operation a presigned request performs
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "camelCase")]
50#[cfg_attr(feature = "openapi", derive(ToSchema))]
51pub enum PresignedOperation {
52    /// Upload/put operation
53    Put,
54    /// Download/get operation  
55    Get,
56    /// Delete operation
57    Delete,
58}
59
60/// Local filesystem operations
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63#[cfg_attr(feature = "openapi", derive(ToSchema))]
64pub enum LocalOperation {
65    Put,
66    Get,
67    Delete,
68}
69
70/// Response from executing a presigned request
71#[derive(Debug)]
72pub struct PresignedResponse {
73    /// HTTP status code (200, 404, etc.) or equivalent
74    pub status_code: u16,
75    /// Response headers
76    pub headers: HashMap<String, String>,
77    /// Response body (for GET operations)
78    pub body: Option<Bytes>,
79}
80
81impl PresignedRequest {
82    /// Create a new HTTP-based presigned request
83    pub fn new_http(
84        url: String,
85        method: String,
86        headers: HashMap<String, String>,
87        operation: PresignedOperation,
88        path: String,
89        expiration: DateTime<Utc>,
90    ) -> Self {
91        Self {
92            backend: PresignedRequestBackend::Http {
93                url,
94                method,
95                headers,
96            },
97            expiration,
98            operation,
99            path,
100        }
101    }
102
103    /// Create a new local filesystem presigned request
104    pub fn new_local(
105        file_path: String,
106        operation: PresignedOperation,
107        path: String,
108        expiration: DateTime<Utc>,
109    ) -> Self {
110        let local_op = match operation {
111            PresignedOperation::Put => LocalOperation::Put,
112            PresignedOperation::Get => LocalOperation::Get,
113            PresignedOperation::Delete => LocalOperation::Delete,
114        };
115
116        Self {
117            backend: PresignedRequestBackend::Local {
118                file_path,
119                operation: local_op,
120            },
121            expiration,
122            operation,
123            path,
124        }
125    }
126
127    /// Execute this presigned request with optional body data.
128    /// For PUT operations, body should contain the data to upload.
129    /// For GET/DELETE operations, body is typically None.
130    pub async fn execute(&self, body: Option<Bytes>) -> Result<PresignedResponse> {
131        match &self.backend {
132            PresignedRequestBackend::Http {
133                url,
134                method,
135                headers,
136            } => self.execute_http(url, method, headers, body).await,
137            PresignedRequestBackend::Local {
138                file_path,
139                operation,
140            } => {
141                #[cfg(feature = "local")]
142                {
143                    self.execute_local(file_path, *operation, body).await
144                }
145                #[cfg(not(feature = "local"))]
146                {
147                    let _ = (file_path, operation);
148                    Err(AlienError::new(ErrorData::FeatureNotEnabled {
149                        feature: "local".to_string(),
150                    }))
151                }
152            }
153        }
154    }
155
156    /// Get a URL representation of this presigned request.
157    /// For local storage, returns a local:// URL.
158    /// For cloud storage, returns the actual presigned URL.
159    pub fn url(&self) -> String {
160        match &self.backend {
161            PresignedRequestBackend::Http { url, .. } => url.clone(),
162            PresignedRequestBackend::Local { file_path, .. } => {
163                format!("local://{}", file_path)
164            }
165        }
166    }
167
168    /// Check if this presigned request has expired
169    pub fn is_expired(&self) -> bool {
170        Utc::now() > self.expiration
171    }
172
173    /// Get the HTTP method for this request (PUT, GET, DELETE)
174    pub fn method(&self) -> &str {
175        match &self.backend {
176            PresignedRequestBackend::Http { method, .. } => method,
177            PresignedRequestBackend::Local { operation, .. } => match operation {
178                LocalOperation::Put => "PUT",
179                LocalOperation::Get => "GET",
180                LocalOperation::Delete => "DELETE",
181            },
182        }
183    }
184
185    /// Get any headers that should be included with this request
186    pub fn headers(&self) -> HashMap<String, String> {
187        match &self.backend {
188            PresignedRequestBackend::Http { headers, .. } => headers.clone(),
189            _ => HashMap::new(),
190        }
191    }
192
193    async fn execute_http(
194        &self,
195        url: &str,
196        method: &str,
197        headers: &HashMap<String, String>,
198        body: Option<Bytes>,
199    ) -> Result<PresignedResponse> {
200        if self.is_expired() {
201            return Err(AlienError::new(ErrorData::PresignedRequestExpired {
202                path: self.path.clone(),
203                expired_at: self.expiration,
204            }));
205        }
206
207        let client = reqwest::Client::new();
208        let mut request = match method {
209            "PUT" => client.put(url),
210            "GET" => client.get(url),
211            "DELETE" => client.delete(url),
212            _ => {
213                return Err(AlienError::new(ErrorData::OperationNotSupported {
214                    operation: format!("HTTP method: {}", method),
215                    reason: "Only PUT, GET, and DELETE are supported".to_string(),
216                }))
217            }
218        };
219
220        // Add headers
221        for (key, value) in headers {
222            request = request.header(key, value);
223        }
224
225        // Add body for PUT requests
226        if let Some(data) = body {
227            request = request.body(data);
228        }
229
230        let response =
231            request
232                .send()
233                .await
234                .into_alien_error()
235                .context(ErrorData::HttpRequestFailed {
236                    url: url.to_string(),
237                    method: method.to_string(),
238                })?;
239
240        let status_code = response.status().as_u16();
241        let response_headers = response
242            .headers()
243            .iter()
244            .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
245            .collect();
246
247        let response_body = if matches!(self.operation, PresignedOperation::Get) {
248            Some(response.bytes().await.into_alien_error().context(
249                ErrorData::HttpRequestFailed {
250                    url: url.to_string(),
251                    method: method.to_string(),
252                },
253            )?)
254        } else {
255            None
256        };
257
258        Ok(PresignedResponse {
259            status_code,
260            headers: response_headers,
261            body: response_body,
262        })
263    }
264
265    #[cfg(feature = "local")]
266    async fn execute_local(
267        &self,
268        file_path: &str,
269        operation: LocalOperation,
270        body: Option<Bytes>,
271    ) -> Result<PresignedResponse> {
272        use std::path::Path as StdPath;
273        use tokio::fs;
274
275        if self.is_expired() {
276            return Err(AlienError::new(ErrorData::PresignedRequestExpired {
277                path: self.path.clone(),
278                expired_at: self.expiration,
279            }));
280        }
281
282        let path = StdPath::new(file_path);
283
284        match operation {
285            LocalOperation::Put => {
286                let data = body.ok_or_else(|| {
287                    AlienError::new(ErrorData::OperationNotSupported {
288                        operation: "Local PUT without body".to_string(),
289                        reason: "PUT operations require body data".to_string(),
290                    })
291                })?;
292
293                // Create parent directories if needed
294                if let Some(parent) = path.parent() {
295                    fs::create_dir_all(parent)
296                        .await
297                        .into_alien_error()
298                        .context(ErrorData::LocalFilesystemError {
299                            path: file_path.to_string(),
300                            operation: "create_parent_dirs".to_string(),
301                        })?;
302                }
303
304                let write_result: std::io::Result<()> = fs::write(path, data.as_ref()).await;
305                write_result
306                    .into_alien_error()
307                    .context(ErrorData::LocalFilesystemError {
308                        path: file_path.to_string(),
309                        operation: "write".to_string(),
310                    })?;
311
312                Ok(PresignedResponse {
313                    status_code: 200,
314                    headers: HashMap::new(),
315                    body: None,
316                })
317            }
318            LocalOperation::Get => {
319                let data = fs::read(path).await.into_alien_error().context(
320                    ErrorData::LocalFilesystemError {
321                        path: file_path.to_string(),
322                        operation: "read".to_string(),
323                    },
324                )?;
325
326                Ok(PresignedResponse {
327                    status_code: 200,
328                    headers: HashMap::new(),
329                    body: Some(Bytes::from(data)),
330                })
331            }
332            LocalOperation::Delete => {
333                fs::remove_file(path).await.into_alien_error().context(
334                    ErrorData::LocalFilesystemError {
335                        path: file_path.to_string(),
336                        operation: "delete".to_string(),
337                    },
338                )?;
339
340                Ok(PresignedResponse {
341                    status_code: 200,
342                    headers: HashMap::new(),
343                    body: None,
344                })
345            }
346        }
347    }
348}