service/http_server/api/v0/bucket/
add.rs

1use axum::extract::{Multipart, State};
2use axum::response::{IntoResponse, Response};
3use serde::{Deserialize, Serialize};
4use std::io::Cursor;
5use std::path::PathBuf;
6use uuid::Uuid;
7
8use common::prelude::Link;
9
10use crate::mount_ops::{add_data_to_bucket, MountOpsError};
11use crate::ServiceState;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[cfg_attr(feature = "clap", derive(clap::Args))]
15pub struct AddRequest {
16    /// Bucket ID to add file to
17    #[cfg_attr(feature = "clap", arg(long))]
18    pub bucket_id: Uuid,
19
20    /// Path in bucket where file should be mounted
21    #[cfg_attr(feature = "clap", arg(long))]
22    pub mount_path: String,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct AddResponse {
27    pub mount_path: String,
28    pub link: Link,
29    pub mime_type: String,
30}
31
32#[axum::debug_handler]
33pub async fn handler(
34    State(state): State<ServiceState>,
35    mut multipart: Multipart,
36) -> Result<impl IntoResponse, AddError> {
37    let mut bucket_id: Option<Uuid> = None;
38    let mut mount_path: Option<String> = None;
39    let mut file_data: Option<Vec<u8>> = None;
40
41    // Parse multipart form data
42    while let Some(field) = multipart
43        .next_field()
44        .await
45        .map_err(|e| AddError::MultipartError(e.to_string()))?
46    {
47        let field_name = field.name().unwrap_or("").to_string();
48
49        match field_name.as_str() {
50            "bucket_id" => {
51                let text = field
52                    .text()
53                    .await
54                    .map_err(|e| AddError::MultipartError(e.to_string()))?;
55                bucket_id = Some(
56                    Uuid::parse_str(&text)
57                        .map_err(|_| AddError::InvalidRequest("Invalid bucket_id".into()))?,
58                );
59            }
60            "mount_path" => {
61                mount_path = Some(
62                    field
63                        .text()
64                        .await
65                        .map_err(|e| AddError::MultipartError(e.to_string()))?,
66                );
67            }
68            "file" => {
69                file_data = Some(
70                    field
71                        .bytes()
72                        .await
73                        .map_err(|e| AddError::MultipartError(e.to_string()))?
74                        .to_vec(),
75                );
76            }
77            _ => {}
78        }
79    }
80
81    let bucket_id =
82        bucket_id.ok_or_else(|| AddError::InvalidRequest("bucket_id is required".into()))?;
83    let mount_path =
84        mount_path.ok_or_else(|| AddError::InvalidRequest("mount_path is required".into()))?;
85    let file_data = file_data.ok_or_else(|| AddError::InvalidRequest("file is required".into()))?;
86
87    // Validate mount path
88    let mount_path_buf = PathBuf::from(&mount_path);
89    if !mount_path_buf.is_absolute() {
90        return Err(AddError::InvalidPath("Mount path must be absolute".into()));
91    }
92
93    // Detect MIME type from file extension
94    let mime_type = mime_guess::from_path(&mount_path_buf)
95        .first_or_octet_stream()
96        .to_string();
97
98    tracing::info!(
99        "Adding file to bucket {} at {} ({})",
100        bucket_id,
101        mount_path,
102        mime_type
103    );
104
105    // Detect MIME type from file extension
106    let mime_type = mime_guess::from_path(&mount_path_buf)
107        .first_or_octet_stream()
108        .to_string();
109    // Clone for blocking task
110    let mount_path_clone = mount_path_buf.clone();
111    let state_clone = state.clone();
112
113    // Run file operations in blocking task
114    let new_bucket_link = tokio::task::spawn_blocking(move || -> Result<Link, MountOpsError> {
115        // Create a cursor from the file data
116        let reader = Cursor::new(file_data);
117        tokio::runtime::Handle::current().block_on(async {
118            let bucket_link =
119                add_data_to_bucket(bucket_id, mount_path_clone, reader, &state_clone).await?;
120            Ok(bucket_link)
121        })
122    })
123    .await
124    .map_err(|e| AddError::Default(anyhow::anyhow!("Task join error: {}", e)))??;
125
126    Ok((
127        http::StatusCode::OK,
128        axum::Json(AddResponse {
129            mount_path,
130            link: new_bucket_link,
131            mime_type,
132        }),
133    )
134        .into_response())
135}
136
137#[derive(Debug, thiserror::Error)]
138pub enum AddError {
139    #[error("Default error: {0}")]
140    Default(anyhow::Error),
141    #[error("Bucket not found: {0}")]
142    BucketNotFound(Uuid),
143    #[error("Invalid path: {0}")]
144    InvalidPath(String),
145    #[error("Invalid request: {0}")]
146    InvalidRequest(String),
147    #[error("Multipart error: {0}")]
148    MultipartError(String),
149    #[error("Database error: {0}")]
150    Database(String),
151    #[error("Storage error: {0}")]
152    MountOps(#[from] MountOpsError),
153}
154
155impl IntoResponse for AddError {
156    fn into_response(self) -> Response {
157        match self {
158            AddError::BucketNotFound(id) => (
159                http::StatusCode::NOT_FOUND,
160                format!("Bucket not found: {}", id),
161            )
162                .into_response(),
163            AddError::InvalidPath(msg)
164            | AddError::InvalidRequest(msg)
165            | AddError::MultipartError(msg) => (
166                http::StatusCode::BAD_REQUEST,
167                format!("Bad request: {}", msg),
168            )
169                .into_response(),
170            AddError::Database(_) | AddError::Default(_) | AddError::MountOps(_) => (
171                http::StatusCode::INTERNAL_SERVER_ERROR,
172                "Unexpected error".to_string(),
173            )
174                .into_response(),
175        }
176    }
177}