feat: add presentation layer + bootstrap wiring for vertical slice
This commit is contained in:
@@ -7,13 +7,15 @@ edition = "2024"
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
api-types = { path = "../api-types" }
|
||||
axum = { workspace = true }
|
||||
axum = { workspace = true, features = ["multipart"] }
|
||||
tower-http = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
utoipa = { workspace = true }
|
||||
utoipa-scalar = { workspace = true }
|
||||
|
||||
72
crates/presentation/src/handlers/albums.rs
Normal file
72
crates/presentation/src/handlers/albums.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use crate::{errors::AppError, extractors::JwtClaims, state::AppState};
|
||||
use api_types::{
|
||||
requests::{AlbumEntryRequest, CreateAlbumRequest},
|
||||
responses::AlbumResponse,
|
||||
};
|
||||
use application::organization::{
|
||||
AlbumAction, CreateAlbumCommand, GetAlbumQuery, ManageAlbumEntriesCommand,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use domain::value_objects::SystemId;
|
||||
|
||||
pub async fn create_album(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
Json(req): Json<CreateAlbumRequest>,
|
||||
) -> Result<(StatusCode, Json<AlbumResponse>), AppError> {
|
||||
let cmd = CreateAlbumCommand {
|
||||
title: req.title,
|
||||
creator_id: claims.user_id,
|
||||
};
|
||||
let album = state.create_album_handler.execute(cmd).await?;
|
||||
Ok((StatusCode::CREATED, Json(AlbumResponse::from_domain(&album))))
|
||||
}
|
||||
|
||||
pub async fn get_album(
|
||||
State(state): State<AppState>,
|
||||
_claims: JwtClaims,
|
||||
Path((album_id,)): Path<(uuid::Uuid,)>,
|
||||
) -> Result<Json<AlbumResponse>, AppError> {
|
||||
let query = GetAlbumQuery {
|
||||
album_id: SystemId::from_uuid(album_id),
|
||||
};
|
||||
let album = state.get_album_handler.execute(query).await?;
|
||||
Ok(Json(AlbumResponse::from_domain(&album)))
|
||||
}
|
||||
|
||||
pub async fn add_entry(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
Path((album_id,)): Path<(uuid::Uuid,)>,
|
||||
Json(req): Json<AlbumEntryRequest>,
|
||||
) -> Result<(StatusCode, Json<AlbumResponse>), AppError> {
|
||||
let cmd = ManageAlbumEntriesCommand {
|
||||
album_id: SystemId::from_uuid(album_id),
|
||||
action: AlbumAction::Add {
|
||||
asset_id: SystemId::from_uuid(req.asset_id),
|
||||
},
|
||||
user_id: claims.user_id,
|
||||
};
|
||||
let album = state.manage_album_entries_handler.execute(cmd).await?;
|
||||
Ok((StatusCode::OK, Json(AlbumResponse::from_domain(&album))))
|
||||
}
|
||||
|
||||
pub async fn remove_entry(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
Path((album_id, asset_id)): Path<(uuid::Uuid, uuid::Uuid)>,
|
||||
) -> Result<Json<AlbumResponse>, AppError> {
|
||||
let cmd = ManageAlbumEntriesCommand {
|
||||
album_id: SystemId::from_uuid(album_id),
|
||||
action: AlbumAction::Remove {
|
||||
asset_id: SystemId::from_uuid(asset_id),
|
||||
},
|
||||
user_id: claims.user_id,
|
||||
};
|
||||
let album = state.manage_album_entries_handler.execute(cmd).await?;
|
||||
Ok(Json(AlbumResponse::from_domain(&album)))
|
||||
}
|
||||
173
crates/presentation/src/handlers/assets.rs
Normal file
173
crates/presentation/src/handlers/assets.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
use crate::{errors::AppError, extractors::JwtClaims, state::AppState};
|
||||
use api_types::{
|
||||
requests::UpdateMetadataRequest,
|
||||
responses::{AssetResponse, IngestResponse, TimelineResponse},
|
||||
};
|
||||
use application::{
|
||||
catalog::{GetAssetQuery, GetTimelineQuery, UpdateMetadataCommand},
|
||||
storage::IngestAssetCommand,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Multipart, Path, Query, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use domain::value_objects::{MetadataValue, StructuredData, SystemId};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct TimelineParams {
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
pub async fn ingest(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
mut 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 mut hasher = Sha256::new();
|
||||
hasher.update(&data);
|
||||
let checksum = format!("{:x}", hasher.finalize());
|
||||
|
||||
let file_size = data.len() as u64;
|
||||
|
||||
let cmd = IngestAssetCommand {
|
||||
uploader_id: claims.user_id,
|
||||
client_device_id,
|
||||
filename: fname,
|
||||
checksum,
|
||||
target_path_id: SystemId::from_uuid(path_id),
|
||||
file_size,
|
||||
data,
|
||||
};
|
||||
|
||||
let (asset, session) = state.ingest_asset_handler.execute(cmd).await?;
|
||||
let empty_meta = StructuredData::new();
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(IngestResponse {
|
||||
asset: AssetResponse::from_domain(&asset, &empty_meta),
|
||||
session_id: *session.session_id.as_uuid(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_asset(
|
||||
State(state): State<AppState>,
|
||||
_claims: JwtClaims,
|
||||
Path((asset_id,)): Path<(uuid::Uuid,)>,
|
||||
) -> Result<Json<AssetResponse>, AppError> {
|
||||
let query = GetAssetQuery {
|
||||
asset_id: SystemId::from_uuid(asset_id),
|
||||
};
|
||||
let (asset, metadata) = state.get_asset_handler.execute(query).await?;
|
||||
Ok(Json(AssetResponse::from_domain(&asset, &metadata)))
|
||||
}
|
||||
|
||||
pub async fn timeline(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
Query(params): Query<TimelineParams>,
|
||||
) -> Result<Json<TimelineResponse>, AppError> {
|
||||
let query = GetTimelineQuery {
|
||||
owner_id: claims.user_id,
|
||||
limit: params.limit.unwrap_or(50),
|
||||
offset: params.offset.unwrap_or(0),
|
||||
};
|
||||
let results = state.get_timeline_handler.execute(query).await?;
|
||||
let total = results.len();
|
||||
let assets = results
|
||||
.iter()
|
||||
.map(|(asset, meta)| AssetResponse::from_domain(asset, meta))
|
||||
.collect();
|
||||
Ok(Json(TimelineResponse { assets, total }))
|
||||
}
|
||||
|
||||
pub async fn update_metadata(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
Path((asset_id,)): Path<(uuid::Uuid,)>,
|
||||
Json(req): Json<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);
|
||||
}
|
||||
|
||||
let cmd = UpdateMetadataCommand {
|
||||
asset_id: SystemId::from_uuid(asset_id),
|
||||
user_id: claims.user_id,
|
||||
data,
|
||||
};
|
||||
state.update_metadata_handler.execute(cmd).await?;
|
||||
Ok(Json(serde_json::json!({ "status": "updated" })))
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
pub mod albums;
|
||||
pub mod assets;
|
||||
pub mod auth;
|
||||
pub mod health;
|
||||
pub mod storage;
|
||||
|
||||
37
crates/presentation/src/handlers/storage.rs
Normal file
37
crates/presentation/src/handlers/storage.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use crate::{errors::AppError, extractors::JwtClaims, state::AppState};
|
||||
use api_types::{
|
||||
requests::{RegisterLibraryPathRequest, RegisterVolumeRequest},
|
||||
responses::{LibraryPathResponse, VolumeResponse},
|
||||
};
|
||||
use application::storage::{RegisterLibraryPathCommand, RegisterVolumeCommand};
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
use domain::value_objects::SystemId;
|
||||
|
||||
pub async fn register_volume(
|
||||
State(state): State<AppState>,
|
||||
_claims: JwtClaims,
|
||||
Json(req): Json<RegisterVolumeRequest>,
|
||||
) -> Result<(StatusCode, Json<VolumeResponse>), AppError> {
|
||||
let cmd = RegisterVolumeCommand {
|
||||
volume_name: req.volume_name,
|
||||
uri_prefix: req.uri_prefix,
|
||||
is_writable: req.is_writable,
|
||||
};
|
||||
let volume = state.register_volume_handler.execute(cmd).await?;
|
||||
Ok((StatusCode::CREATED, Json(VolumeResponse::from_domain(&volume))))
|
||||
}
|
||||
|
||||
pub async fn register_library_path(
|
||||
State(state): State<AppState>,
|
||||
_claims: JwtClaims,
|
||||
Json(req): Json<RegisterLibraryPathRequest>,
|
||||
) -> Result<(StatusCode, Json<LibraryPathResponse>), AppError> {
|
||||
let cmd = RegisterLibraryPathCommand {
|
||||
volume_id: SystemId::from_uuid(req.volume_id),
|
||||
relative_path: req.relative_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?;
|
||||
Ok((StatusCode::CREATED, Json(LibraryPathResponse::from_domain(&path))))
|
||||
}
|
||||
@@ -1,18 +1,32 @@
|
||||
use crate::{
|
||||
handlers::{auth, health},
|
||||
handlers::{albums, assets, auth, health, storage},
|
||||
openapi::openapi_router,
|
||||
state::AppState,
|
||||
};
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
routing::{delete, get, post, put},
|
||||
};
|
||||
|
||||
pub fn api_v1_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
// auth
|
||||
.route("/auth/register", post(auth::register))
|
||||
.route("/auth/login", post(auth::login))
|
||||
.route("/auth/me", get(auth::me))
|
||||
// albums
|
||||
.route("/albums", post(albums::create_album))
|
||||
.route("/albums/:id", get(albums::get_album))
|
||||
.route("/albums/:id/entries", post(albums::add_entry))
|
||||
.route("/albums/:id/entries/:asset_id", delete(albums::remove_entry))
|
||||
// assets
|
||||
.route("/assets/ingest", post(assets::ingest))
|
||||
.route("/assets/timeline", get(assets::timeline))
|
||||
.route("/assets/:id", get(assets::get_asset))
|
||||
.route("/assets/:id/metadata", put(assets::update_metadata))
|
||||
// storage
|
||||
.route("/storage/volumes", post(storage::register_volume))
|
||||
.route("/storage/library-paths", post(storage::register_library_path))
|
||||
}
|
||||
|
||||
pub fn app_router() -> Router<AppState> {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
use application::identity::{GetProfileHandler, LoginUserHandler, RegisterUserHandler};
|
||||
use application::{
|
||||
catalog::{GetAssetHandler, GetTimelineHandler, UpdateMetadataHandler},
|
||||
identity::{GetProfileHandler, LoginUserHandler, RegisterUserHandler},
|
||||
organization::{CreateAlbumHandler, GetAlbumHandler, ManageAlbumEntriesHandler},
|
||||
storage::{IngestAssetHandler, RegisterLibraryPathHandler, RegisterVolumeHandler},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{StoragePort, TokenIssuer};
|
||||
@@ -10,15 +15,34 @@ pub struct AppState {
|
||||
pub get_profile_handler: Arc<GetProfileHandler>,
|
||||
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>,
|
||||
}
|
||||
|
||||
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>,
|
||||
) -> Self {
|
||||
Self {
|
||||
register_handler,
|
||||
@@ -26,6 +50,15 @@ impl AppState {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user