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#[derive(Eq, PartialEq, Debug, Clone, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15#[cfg_attr(feature = "openapi", derive(ToSchema))]
16pub struct PresignedRequest {
17 pub backend: PresignedRequestBackend,
19 pub expiration: DateTime<Utc>,
21 pub operation: PresignedOperation,
23 pub path: String,
25}
26
27#[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 #[serde(rename_all = "camelCase")]
34 Http {
35 url: String,
36 method: String,
37 headers: HashMap<String, String>,
38 },
39 #[serde(rename_all = "camelCase")]
41 Local {
42 file_path: String,
43 operation: LocalOperation,
44 },
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "camelCase")]
50#[cfg_attr(feature = "openapi", derive(ToSchema))]
51pub enum PresignedOperation {
52 Put,
54 Get,
56 Delete,
58}
59
60#[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#[derive(Debug)]
72pub struct PresignedResponse {
73 pub status_code: u16,
75 pub headers: HashMap<String, String>,
77 pub body: Option<Bytes>,
79}
80
81impl PresignedRequest {
82 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 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 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 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 pub fn is_expired(&self) -> bool {
170 Utc::now() > self.expiration
171 }
172
173 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 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 for (key, value) in headers {
222 request = request.header(key, value);
223 }
224
225 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 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}