refactor: clean up presentation layer — AppState grouping, multipart extractor, thin handlers
This commit is contained in:
3
crates/presentation/src/constants.rs
Normal file
3
crates/presentation/src/constants.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub const DEFAULT_PAGE_SIZE: u32 = 50;
|
||||
pub const MAX_PAGE_SIZE: u32 = 1000;
|
||||
pub const DEFAULT_DEVICE_ID: &str = "web";
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod auth;
|
||||
pub mod json;
|
||||
pub mod multipart_upload;
|
||||
|
||||
pub use auth::JwtClaims;
|
||||
pub use json::ValidatedJson;
|
||||
pub use multipart_upload::UploadedAsset;
|
||||
|
||||
74
crates/presentation/src/extractors/multipart_upload.rs
Normal file
74
crates/presentation/src/extractors/multipart_upload.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use axum::extract::Multipart;
|
||||
use bytes::Bytes;
|
||||
use domain::errors::DomainError;
|
||||
use domain::value_objects::SystemId;
|
||||
|
||||
use crate::constants::DEFAULT_DEVICE_ID;
|
||||
use crate::errors::AppError;
|
||||
|
||||
pub struct UploadedAsset {
|
||||
pub filename: String,
|
||||
pub data: Bytes,
|
||||
pub target_path_id: SystemId,
|
||||
pub client_device_id: String,
|
||||
}
|
||||
|
||||
impl UploadedAsset {
|
||||
pub async fn from_multipart(mut multipart: Multipart) -> Result<Self, AppError> {
|
||||
let mut file_data: Option<Bytes> = None;
|
||||
let mut filename: Option<String> = None;
|
||||
let mut target_path_id: Option<uuid::Uuid> = None;
|
||||
let mut client_device_id = DEFAULT_DEVICE_ID.to_string();
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| AppError::from(DomainError::Validation(e.to_string())))?
|
||||
{
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
match name.as_str() {
|
||||
"file" => {
|
||||
filename = field.file_name().map(|s| s.to_string());
|
||||
file_data = Some(
|
||||
field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::from(DomainError::Internal(e.to_string())))?,
|
||||
);
|
||||
}
|
||||
"target_path_id" => {
|
||||
let text = field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::from(DomainError::Validation(e.to_string())))?;
|
||||
target_path_id = Some(
|
||||
text.parse::<uuid::Uuid>()
|
||||
.map_err(|e| AppError::from(DomainError::Validation(e.to_string())))?,
|
||||
);
|
||||
}
|
||||
"client_device_id" => {
|
||||
client_device_id = field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::from(DomainError::Validation(e.to_string())))?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let data = file_data
|
||||
.ok_or_else(|| AppError::from(DomainError::Validation("Missing file field".into())))?;
|
||||
let fname = filename
|
||||
.ok_or_else(|| AppError::from(DomainError::Validation("Missing filename".into())))?;
|
||||
let path_id = target_path_id.ok_or_else(|| {
|
||||
AppError::from(DomainError::Validation("Missing target_path_id".into()))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
filename: fname,
|
||||
data,
|
||||
target_path_id: SystemId::from_uuid(path_id),
|
||||
client_device_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ pub async fn create_album(
|
||||
title: req.title,
|
||||
creator_id: claims.user_id,
|
||||
};
|
||||
let album = state.create_album_handler.execute(cmd).await?;
|
||||
let album = state.organization.create_album.execute(cmd).await?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(AlbumResponse::from_domain(&album)),
|
||||
@@ -38,7 +38,7 @@ pub async fn get_album(
|
||||
album_id: SystemId::from_uuid(album_id),
|
||||
user_id: claims.user_id,
|
||||
};
|
||||
let album = state.get_album_handler.execute(query).await?;
|
||||
let album = state.organization.get_album.execute(query).await?;
|
||||
Ok(Json(AlbumResponse::from_domain(&album)))
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ pub async fn add_entry(
|
||||
},
|
||||
user_id: claims.user_id,
|
||||
};
|
||||
let album = state.manage_album_entries_handler.execute(cmd).await?;
|
||||
let album = state.organization.manage_album_entries.execute(cmd).await?;
|
||||
Ok((StatusCode::OK, Json(AlbumResponse::from_domain(&album))))
|
||||
}
|
||||
|
||||
@@ -71,6 +71,6 @@ pub async fn remove_entry(
|
||||
},
|
||||
user_id: claims.user_id,
|
||||
};
|
||||
let album = state.manage_album_entries_handler.execute(cmd).await?;
|
||||
let album = state.organization.manage_album_entries.execute(cmd).await?;
|
||||
Ok(Json(AlbumResponse::from_domain(&album)))
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use crate::{errors::AppError, extractors::JwtClaims, state::AppState};
|
||||
use api_types::{
|
||||
requests::UpdateMetadataRequest,
|
||||
responses::{AssetResponse, IngestResponse, TimelineResponse},
|
||||
use crate::{
|
||||
constants::{DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE},
|
||||
errors::AppError,
|
||||
extractors::{JwtClaims, UploadedAsset},
|
||||
state::AppState,
|
||||
};
|
||||
use api_types::responses::{AssetResponse, IngestResponse, TimelineResponse};
|
||||
use application::{
|
||||
catalog::{GetAssetQuery, GetTimelineQuery, UpdateMetadataCommand},
|
||||
catalog::{GetAssetQuery, GetTimelineQuery, ReadAssetFileQuery, UpdateMetadataCommand},
|
||||
storage::IngestAssetCommand,
|
||||
};
|
||||
use axum::{
|
||||
@@ -25,78 +27,25 @@ pub struct TimelineParams {
|
||||
pub async fn ingest(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
mut multipart: Multipart,
|
||||
multipart: Multipart,
|
||||
) -> Result<(StatusCode, Json<IngestResponse>), AppError> {
|
||||
let mut file_data: Option<bytes::Bytes> = None;
|
||||
let mut filename: Option<String> = None;
|
||||
let mut target_path_id: Option<uuid::Uuid> = None;
|
||||
let mut client_device_id = "web".to_string();
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| AppError::from(domain::errors::DomainError::Validation(e.to_string())))?
|
||||
{
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
match name.as_str() {
|
||||
"file" => {
|
||||
filename = field.file_name().map(|s| s.to_string());
|
||||
let data = field.bytes().await.map_err(|e| {
|
||||
AppError::from(domain::errors::DomainError::Internal(e.to_string()))
|
||||
})?;
|
||||
file_data = Some(data);
|
||||
}
|
||||
"target_path_id" => {
|
||||
let text = field.text().await.map_err(|e| {
|
||||
AppError::from(domain::errors::DomainError::Validation(e.to_string()))
|
||||
})?;
|
||||
target_path_id = Some(text.parse::<uuid::Uuid>().map_err(|e| {
|
||||
AppError::from(domain::errors::DomainError::Validation(e.to_string()))
|
||||
})?);
|
||||
}
|
||||
"client_device_id" => {
|
||||
client_device_id = field.text().await.map_err(|e| {
|
||||
AppError::from(domain::errors::DomainError::Validation(e.to_string()))
|
||||
})?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let data = file_data.ok_or_else(|| {
|
||||
AppError::from(domain::errors::DomainError::Validation(
|
||||
"Missing file field".to_string(),
|
||||
))
|
||||
})?;
|
||||
let fname = filename.ok_or_else(|| {
|
||||
AppError::from(domain::errors::DomainError::Validation(
|
||||
"Missing filename".to_string(),
|
||||
))
|
||||
})?;
|
||||
let path_id = target_path_id.ok_or_else(|| {
|
||||
AppError::from(domain::errors::DomainError::Validation(
|
||||
"Missing target_path_id".to_string(),
|
||||
))
|
||||
})?;
|
||||
|
||||
let file_size = data.len() as u64;
|
||||
let upload = UploadedAsset::from_multipart(multipart).await?;
|
||||
|
||||
let cmd = IngestAssetCommand {
|
||||
uploader_id: claims.user_id,
|
||||
client_device_id,
|
||||
filename: fname,
|
||||
target_path_id: SystemId::from_uuid(path_id),
|
||||
file_size,
|
||||
data,
|
||||
client_device_id: upload.client_device_id,
|
||||
filename: upload.filename,
|
||||
target_path_id: upload.target_path_id,
|
||||
file_size: upload.data.len() as u64,
|
||||
data: upload.data,
|
||||
};
|
||||
|
||||
let (asset, session) = state.ingest_asset_handler.execute(cmd).await?;
|
||||
let empty_meta = StructuredData::new();
|
||||
let (asset, session) = state.catalog.ingest_asset.execute(cmd).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(IngestResponse {
|
||||
asset: AssetResponse::from_domain(&asset, &empty_meta),
|
||||
asset: AssetResponse::from_domain(&asset, &StructuredData::new()),
|
||||
session_id: *session.session_id.as_uuid(),
|
||||
}),
|
||||
))
|
||||
@@ -111,7 +60,7 @@ pub async fn get_asset(
|
||||
asset_id: SystemId::from_uuid(asset_id),
|
||||
user_id: claims.user_id,
|
||||
};
|
||||
let (asset, metadata) = state.get_asset_handler.execute(query).await?;
|
||||
let (asset, metadata) = state.catalog.get_asset.execute(query).await?;
|
||||
Ok(Json(AssetResponse::from_domain(&asset, &metadata)))
|
||||
}
|
||||
|
||||
@@ -122,10 +71,10 @@ pub async fn timeline(
|
||||
) -> Result<Json<TimelineResponse>, AppError> {
|
||||
let query = GetTimelineQuery {
|
||||
owner_id: claims.user_id,
|
||||
limit: params.limit.unwrap_or(50),
|
||||
limit: params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE),
|
||||
offset: params.offset.unwrap_or(0),
|
||||
};
|
||||
let results = state.get_timeline_handler.execute(query).await?;
|
||||
let results = state.catalog.get_timeline.execute(query).await?;
|
||||
let total = results.len();
|
||||
let assets = results
|
||||
.iter()
|
||||
@@ -138,26 +87,11 @@ pub async fn update_metadata(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
Path((asset_id,)): Path<(uuid::Uuid,)>,
|
||||
Json(req): Json<UpdateMetadataRequest>,
|
||||
Json(req): Json<api_types::requests::UpdateMetadataRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let mut data = StructuredData::new();
|
||||
for (k, v) in req.data {
|
||||
let mv = match v {
|
||||
serde_json::Value::String(s) => MetadataValue::String(s),
|
||||
serde_json::Value::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
MetadataValue::Integer(i)
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
MetadataValue::Float(f)
|
||||
} else {
|
||||
MetadataValue::Null
|
||||
}
|
||||
}
|
||||
serde_json::Value::Bool(b) => MetadataValue::Boolean(b),
|
||||
serde_json::Value::Null => MetadataValue::Null,
|
||||
_ => MetadataValue::String(v.to_string()),
|
||||
};
|
||||
data.insert(k, mv);
|
||||
data.insert(k, MetadataValue::from(v));
|
||||
}
|
||||
|
||||
let cmd = UpdateMetadataCommand {
|
||||
@@ -165,7 +99,7 @@ pub async fn update_metadata(
|
||||
user_id: claims.user_id,
|
||||
data,
|
||||
};
|
||||
state.update_metadata_handler.execute(cmd).await?;
|
||||
state.catalog.update_metadata.execute(cmd).await?;
|
||||
Ok(Json(serde_json::json!({ "status": "updated" })))
|
||||
}
|
||||
|
||||
@@ -174,33 +108,19 @@ pub async fn serve_file(
|
||||
_claims: JwtClaims,
|
||||
Path((asset_id,)): Path<(uuid::Uuid,)>,
|
||||
) -> Result<Response, AppError> {
|
||||
let asset = state
|
||||
.asset_repo
|
||||
.find_by_id(&SystemId::from_uuid(asset_id))
|
||||
.await?
|
||||
.ok_or_else(|| domain::errors::DomainError::NotFound("Asset not found".into()))?;
|
||||
let query = ReadAssetFileQuery {
|
||||
asset_id: SystemId::from_uuid(asset_id),
|
||||
};
|
||||
let result = state.catalog.read_asset_file.execute(query).await?;
|
||||
|
||||
let data = state
|
||||
.file_storage
|
||||
.read_file(&asset.source_reference.relative_path)
|
||||
.await?;
|
||||
|
||||
Ok(Response::builder()
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, &asset.mime_type)
|
||||
.header(header::CONTENT_LENGTH, data.len())
|
||||
.header(header::CONTENT_TYPE, &result.mime_type)
|
||||
.header(header::CONTENT_LENGTH, result.data.len())
|
||||
.header(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!(
|
||||
"inline; filename=\"{}\"",
|
||||
asset
|
||||
.source_reference
|
||||
.relative_path
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or("file")
|
||||
),
|
||||
format!("inline; filename=\"{}\"", result.filename),
|
||||
)
|
||||
.body(Body::from(data))
|
||||
.unwrap())
|
||||
.body(Body::from(result.data))
|
||||
.map_err(|e| AppError::from(domain::errors::DomainError::Internal(e.to_string())))
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ pub async fn register(
|
||||
email: req.email,
|
||||
password: req.password,
|
||||
};
|
||||
let user = state.register_handler.execute(cmd).await?;
|
||||
let user = state.identity.register.execute(cmd).await?;
|
||||
let token = state
|
||||
.token_issuer
|
||||
.issue(&user.id, "user")
|
||||
@@ -59,7 +59,7 @@ pub async fn login(
|
||||
email: req.email,
|
||||
password: req.password,
|
||||
};
|
||||
let (user, token) = state.login_handler.execute(cmd).await?;
|
||||
let (user, token) = state.identity.login.execute(cmd).await?;
|
||||
Ok(Json(AuthResponse {
|
||||
token,
|
||||
user: UserResponse::from_domain(&user),
|
||||
@@ -81,6 +81,6 @@ pub async fn me(
|
||||
let query = GetProfileQuery {
|
||||
user_id: claims.user_id,
|
||||
};
|
||||
let user = state.get_profile_handler.execute(query).await?;
|
||||
let user = state.identity.get_profile.execute(query).await?;
|
||||
Ok(Json(UserResponse::from_domain(&user)))
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ pub async fn register_volume(
|
||||
uri_prefix: req.uri_prefix,
|
||||
is_writable: req.is_writable,
|
||||
};
|
||||
let volume = state.register_volume_handler.execute(cmd).await?;
|
||||
let volume = state.storage.register_volume.execute(cmd).await?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(VolumeResponse::from_domain(&volume)),
|
||||
@@ -35,7 +35,7 @@ pub async fn register_library_path(
|
||||
owner_id: SystemId::from_uuid(req.owner_id),
|
||||
is_ingest_destination: req.is_ingest_destination,
|
||||
};
|
||||
let path = state.register_library_path_handler.execute(cmd).await?;
|
||||
let path = state.storage.register_library_path.execute(cmd).await?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(LibraryPathResponse::from_domain(&path)),
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
// Example: stream a stored file as an HTTP response.
|
||||
// Remove this file or replace with your own handlers.
|
||||
//
|
||||
// To use, add to your router:
|
||||
// .route("/files/*key", get(storage_example::get_file))
|
||||
//
|
||||
// use axum::{
|
||||
// body::Body,
|
||||
// extract::{Path, State},
|
||||
// http::StatusCode,
|
||||
// response::IntoResponse,
|
||||
// };
|
||||
// use futures::StreamExt;
|
||||
// use crate::state::AppState;
|
||||
//
|
||||
// pub async fn get_file(
|
||||
// Path(key): Path<String>,
|
||||
// State(state): State<AppState>,
|
||||
// ) -> Result<impl IntoResponse, StatusCode> {
|
||||
// let stream = state
|
||||
// .storage
|
||||
// .get(&key)
|
||||
// .await
|
||||
// .map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
// let body = Body::from_stream(stream.map(|r| r.map_err(|e| e.to_string())));
|
||||
// Ok(body)
|
||||
// }
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod constants;
|
||||
pub mod errors;
|
||||
pub mod extractors;
|
||||
pub mod handlers;
|
||||
|
||||
@@ -1,70 +1,47 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::{
|
||||
catalog::{GetAssetHandler, GetTimelineHandler, UpdateMetadataHandler},
|
||||
catalog::{GetAssetHandler, GetTimelineHandler, ReadAssetFileHandler, UpdateMetadataHandler},
|
||||
identity::{GetProfileHandler, LoginUserHandler, RegisterUserHandler},
|
||||
organization::{CreateAlbumHandler, GetAlbumHandler, ManageAlbumEntriesHandler},
|
||||
storage::{IngestAssetHandler, RegisterLibraryPathHandler, RegisterVolumeHandler},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use domain::ports::TokenIssuer;
|
||||
|
||||
use domain::ports::{AssetRepository, FileStoragePort, StoragePort, TokenIssuer};
|
||||
#[derive(Clone)]
|
||||
pub struct IdentityHandlers {
|
||||
pub register: Arc<RegisterUserHandler>,
|
||||
pub login: Arc<LoginUserHandler>,
|
||||
pub get_profile: Arc<GetProfileHandler>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CatalogHandlers {
|
||||
pub ingest_asset: Arc<IngestAssetHandler>,
|
||||
pub get_asset: Arc<GetAssetHandler>,
|
||||
pub get_timeline: Arc<GetTimelineHandler>,
|
||||
pub update_metadata: Arc<UpdateMetadataHandler>,
|
||||
pub read_asset_file: Arc<ReadAssetFileHandler>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OrganizationHandlers {
|
||||
pub create_album: Arc<CreateAlbumHandler>,
|
||||
pub get_album: Arc<GetAlbumHandler>,
|
||||
pub manage_album_entries: Arc<ManageAlbumEntriesHandler>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct StorageHandlers {
|
||||
pub register_volume: Arc<RegisterVolumeHandler>,
|
||||
pub register_library_path: Arc<RegisterLibraryPathHandler>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub register_handler: Arc<RegisterUserHandler>,
|
||||
pub login_handler: Arc<LoginUserHandler>,
|
||||
pub get_profile_handler: Arc<GetProfileHandler>,
|
||||
pub identity: IdentityHandlers,
|
||||
pub catalog: CatalogHandlers,
|
||||
pub organization: OrganizationHandlers,
|
||||
pub storage: StorageHandlers,
|
||||
pub token_issuer: Arc<dyn TokenIssuer>,
|
||||
pub storage: Arc<dyn StoragePort>,
|
||||
pub create_album_handler: Arc<CreateAlbumHandler>,
|
||||
pub get_album_handler: Arc<GetAlbumHandler>,
|
||||
pub manage_album_entries_handler: Arc<ManageAlbumEntriesHandler>,
|
||||
pub ingest_asset_handler: Arc<IngestAssetHandler>,
|
||||
pub get_asset_handler: Arc<GetAssetHandler>,
|
||||
pub get_timeline_handler: Arc<GetTimelineHandler>,
|
||||
pub update_metadata_handler: Arc<UpdateMetadataHandler>,
|
||||
pub register_volume_handler: Arc<RegisterVolumeHandler>,
|
||||
pub register_library_path_handler: Arc<RegisterLibraryPathHandler>,
|
||||
pub file_storage: Arc<dyn FileStoragePort>,
|
||||
pub asset_repo: Arc<dyn AssetRepository>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
register_handler: Arc<RegisterUserHandler>,
|
||||
login_handler: Arc<LoginUserHandler>,
|
||||
get_profile_handler: Arc<GetProfileHandler>,
|
||||
token_issuer: Arc<dyn TokenIssuer>,
|
||||
storage: Arc<dyn StoragePort>,
|
||||
create_album_handler: Arc<CreateAlbumHandler>,
|
||||
get_album_handler: Arc<GetAlbumHandler>,
|
||||
manage_album_entries_handler: Arc<ManageAlbumEntriesHandler>,
|
||||
ingest_asset_handler: Arc<IngestAssetHandler>,
|
||||
get_asset_handler: Arc<GetAssetHandler>,
|
||||
get_timeline_handler: Arc<GetTimelineHandler>,
|
||||
update_metadata_handler: Arc<UpdateMetadataHandler>,
|
||||
register_volume_handler: Arc<RegisterVolumeHandler>,
|
||||
register_library_path_handler: Arc<RegisterLibraryPathHandler>,
|
||||
file_storage: Arc<dyn FileStoragePort>,
|
||||
asset_repo: Arc<dyn AssetRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
register_handler,
|
||||
login_handler,
|
||||
get_profile_handler,
|
||||
token_issuer,
|
||||
storage,
|
||||
create_album_handler,
|
||||
get_album_handler,
|
||||
manage_album_entries_handler,
|
||||
ingest_asset_handler,
|
||||
get_asset_handler,
|
||||
get_timeline_handler,
|
||||
update_metadata_handler,
|
||||
register_volume_handler,
|
||||
register_library_path_handler,
|
||||
file_storage,
|
||||
asset_repo,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user