feat: add presentation layer + bootstrap wiring for vertical slice
This commit is contained in:
45
.env.example
45
.env.example
@@ -1,65 +1,42 @@
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# K-Template Configuration
|
# K-Photos Configuration
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Copy this file to .env and adjust values for your environment.
|
# Copy this file to .env and adjust values for your environment.
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Server
|
# Server
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
HOST=127.0.0.1
|
HOST=0.0.0.0
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Database
|
# Database
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# SQLite (default)
|
DATABASE_URL=postgres://kphotos:kphotos@localhost:5432/kphotos
|
||||||
DATABASE_URL=sqlite:data.db?mode=rwc
|
|
||||||
|
|
||||||
# PostgreSQL (requires postgres feature flag)
|
|
||||||
# DATABASE_URL=postgres://user:password@localhost:5432/mydb
|
|
||||||
|
|
||||||
DB_MAX_CONNECTIONS=5
|
DB_MAX_CONNECTIONS=5
|
||||||
DB_MIN_CONNECTIONS=1
|
DB_MIN_CONNECTIONS=1
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Cookie Secret
|
|
||||||
# ============================================================================
|
|
||||||
# Used to encrypt the OIDC state cookie (CSRF token, PKCE verifier, nonce).
|
|
||||||
# Must be at least 64 characters in production.
|
|
||||||
COOKIE_SECRET=your-cookie-secret-key-must-be-at-least-64-characters-long-for-security!!
|
|
||||||
|
|
||||||
# Set to true when serving over HTTPS
|
|
||||||
SECURE_COOKIE=false
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# JWT
|
# JWT
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Must be at least 32 characters in production.
|
JWT_SECRET=change-me-in-production-at-least-32-characters
|
||||||
JWT_SECRET=your-jwt-secret-key-at-least-32-chars
|
|
||||||
|
|
||||||
# Optional: embed issuer/audience claims in tokens
|
|
||||||
# JWT_ISSUER=your-app-name
|
|
||||||
# JWT_AUDIENCE=your-app-audience
|
|
||||||
|
|
||||||
# Token lifetime in hours (default: 24)
|
# Token lifetime in hours (default: 24)
|
||||||
JWT_EXPIRY_HOURS=24
|
JWT_EXPIRY_HOURS=24
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# OIDC (optional — requires auth-oidc feature flag)
|
|
||||||
# ============================================================================
|
|
||||||
# OIDC_ISSUER=https://your-oidc-provider.com
|
|
||||||
# OIDC_CLIENT_ID=your-client-id
|
|
||||||
# OIDC_CLIENT_SECRET=your-client-secret
|
|
||||||
# OIDC_REDIRECT_URL=http://localhost:3000/api/v1/auth/callback
|
|
||||||
# OIDC_RESOURCE_ID=your-resource-id # optional audience claim to verify
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# CORS
|
# CORS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
|
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Storage
|
||||||
|
# ============================================================================
|
||||||
|
STORAGE_BACKEND=local
|
||||||
|
STORAGE_PATH=./data/media
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Production Mode
|
# Production Mode
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Set to true/production/1 to enforce minimum secret lengths and other checks.
|
|
||||||
PRODUCTION=false
|
PRODUCTION=false
|
||||||
|
|||||||
31
Cargo.lock
generated
31
Cargo.lock
generated
@@ -81,6 +81,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
@@ -154,6 +155,7 @@ dependencies = [
|
|||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
|
"multer",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -260,6 +262,7 @@ dependencies = [
|
|||||||
"adapters-storage",
|
"adapters-storage",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"application",
|
"application",
|
||||||
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"domain",
|
"domain",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
@@ -486,6 +489,15 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -1195,6 +1207,23 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multer"
|
||||||
|
version = "3.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"spin",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -1434,10 +1463,12 @@ dependencies = [
|
|||||||
"application",
|
"application",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
|
|||||||
14
compose.yml
Normal file
14
compose.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: kphotos
|
||||||
|
POSTGRES_PASSWORD: kphotos
|
||||||
|
POSTGRES_DB: kphotos
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -15,6 +15,7 @@ anyhow = { workspace = true }
|
|||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
bytes = { workspace = true }
|
bytes = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
|
tokio = { workspace = true, features = ["fs"] }
|
||||||
object_store = { version = "0.11" }
|
object_store = { version = "0.11" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod adapter;
|
pub mod adapter;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod local_file_storage;
|
||||||
|
|
||||||
pub use adapter::ObjectStorageAdapter;
|
pub use adapter::ObjectStorageAdapter;
|
||||||
pub use config::{StorageConfig, build_store};
|
pub use config::{StorageConfig, build_store};
|
||||||
|
pub use local_file_storage::LocalFileStorage;
|
||||||
|
|||||||
101
crates/adapters/storage/src/local_file_storage.rs
Normal file
101
crates/adapters/storage/src/local_file_storage.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::ports::{FileEntry, FileStoragePort};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub struct LocalFileStorage {
|
||||||
|
base_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalFileStorage {
|
||||||
|
pub fn new(base_path: impl Into<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
base_path: base_path.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve(&self, path: &str) -> Result<PathBuf, DomainError> {
|
||||||
|
let full = self.base_path.join(path);
|
||||||
|
// Prevent path traversal
|
||||||
|
if !full.starts_with(&self.base_path) {
|
||||||
|
return Err(DomainError::Validation(
|
||||||
|
"Path traversal not allowed".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FileStoragePort for LocalFileStorage {
|
||||||
|
async fn store_file(&self, path: &str, data: Bytes) -> Result<(), DomainError> {
|
||||||
|
let full = self.resolve(path)?;
|
||||||
|
if let Some(parent) = full.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(format!("Failed to create dirs: {e}")))?;
|
||||||
|
}
|
||||||
|
tokio::fs::write(&full, &data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(format!("Failed to write file: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_file(&self, path: &str) -> Result<Bytes, DomainError> {
|
||||||
|
let full = self.resolve(path)?;
|
||||||
|
let data = tokio::fs::read(&full)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match e.kind() {
|
||||||
|
std::io::ErrorKind::NotFound => DomainError::NotFound(path.to_string()),
|
||||||
|
_ => DomainError::Internal(format!("Failed to read file: {e}")),
|
||||||
|
})?;
|
||||||
|
Ok(Bytes::from(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_file(&self, path: &str) -> Result<(), DomainError> {
|
||||||
|
let full = self.resolve(path)?;
|
||||||
|
match tokio::fs::remove_file(&full).await {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(DomainError::Internal(format!("Failed to delete file: {e}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_directory(&self, path: &str) -> Result<Vec<FileEntry>, DomainError> {
|
||||||
|
let full = self.resolve(path)?;
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
let mut read_dir = tokio::fs::read_dir(&full)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match e.kind() {
|
||||||
|
std::io::ErrorKind::NotFound => DomainError::NotFound(path.to_string()),
|
||||||
|
_ => DomainError::Internal(format!("Failed to read dir: {e}")),
|
||||||
|
})?;
|
||||||
|
while let Some(entry) = read_dir
|
||||||
|
.next_entry()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?
|
||||||
|
{
|
||||||
|
let meta = entry
|
||||||
|
.metadata()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
entries.push(FileEntry {
|
||||||
|
path: entry.file_name().to_string_lossy().to_string(),
|
||||||
|
size_bytes: meta.len(),
|
||||||
|
is_directory: meta.is_dir(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn file_exists(&self, path: &str) -> Result<bool, DomainError> {
|
||||||
|
let full = self.resolve(path)?;
|
||||||
|
Ok(full.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn available_space(&self) -> Result<u64, DomainError> {
|
||||||
|
// Simple stub: return a large number
|
||||||
|
Ok(u64::MAX)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,9 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
uuid = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
chrono = { workspace = true }
|
uuid = { workspace = true }
|
||||||
utoipa = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
utoipa = { workspace = true }
|
||||||
|
|||||||
@@ -15,3 +15,28 @@ pub struct LoginRequest {
|
|||||||
pub struct CreateAlbumRequest {
|
pub struct CreateAlbumRequest {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct AlbumEntryRequest {
|
||||||
|
pub asset_id: uuid::Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct RegisterVolumeRequest {
|
||||||
|
pub volume_name: String,
|
||||||
|
pub uri_prefix: String,
|
||||||
|
pub is_writable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct RegisterLibraryPathRequest {
|
||||||
|
pub volume_id: uuid::Uuid,
|
||||||
|
pub relative_path: String,
|
||||||
|
pub owner_id: uuid::Uuid,
|
||||||
|
pub is_ingest_destination: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct UpdateMetadataRequest {
|
||||||
|
pub data: std::collections::HashMap<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ pub struct AlbumResponse {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub creator_id: Uuid,
|
pub creator_id: Uuid,
|
||||||
|
pub asset_count: usize,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +43,109 @@ impl AlbumResponse {
|
|||||||
title: album.title.clone(),
|
title: album.title.clone(),
|
||||||
description: album.description.clone(),
|
description: album.description.clone(),
|
||||||
creator_id: *album.creator_user_id.as_uuid(),
|
creator_id: *album.creator_user_id.as_uuid(),
|
||||||
|
asset_count: album.asset_count(),
|
||||||
created_at: *album.created_at.as_datetime(),
|
created_at: *album.created_at.as_datetime(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct AssetResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub asset_type: String,
|
||||||
|
pub mime_type: String,
|
||||||
|
pub file_size: u64,
|
||||||
|
pub is_processed: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub metadata: std::collections::HashMap<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AssetResponse {
|
||||||
|
pub fn from_domain(
|
||||||
|
asset: &domain::entities::Asset,
|
||||||
|
metadata: &domain::value_objects::StructuredData,
|
||||||
|
) -> Self {
|
||||||
|
let meta_map = metadata
|
||||||
|
.inner()
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| {
|
||||||
|
let json_val = match v {
|
||||||
|
domain::value_objects::MetadataValue::String(s) => {
|
||||||
|
serde_json::Value::String(s.clone())
|
||||||
|
}
|
||||||
|
domain::value_objects::MetadataValue::Integer(i) => {
|
||||||
|
serde_json::json!(*i)
|
||||||
|
}
|
||||||
|
domain::value_objects::MetadataValue::Float(f) => {
|
||||||
|
serde_json::json!(*f)
|
||||||
|
}
|
||||||
|
domain::value_objects::MetadataValue::Boolean(b) => {
|
||||||
|
serde_json::Value::Bool(*b)
|
||||||
|
}
|
||||||
|
domain::value_objects::MetadataValue::Null => serde_json::Value::Null,
|
||||||
|
};
|
||||||
|
(k.clone(), json_val)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
id: *asset.asset_id.as_uuid(),
|
||||||
|
asset_type: format!("{:?}", asset.asset_type),
|
||||||
|
mime_type: asset.mime_type.clone(),
|
||||||
|
file_size: asset.file_size,
|
||||||
|
is_processed: asset.is_processed,
|
||||||
|
created_at: *asset.created_at.as_datetime(),
|
||||||
|
metadata: meta_map,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct TimelineResponse {
|
||||||
|
pub assets: Vec<AssetResponse>,
|
||||||
|
pub total: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct VolumeResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub volume_name: String,
|
||||||
|
pub uri_prefix: String,
|
||||||
|
pub is_writable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VolumeResponse {
|
||||||
|
pub fn from_domain(volume: &domain::entities::StorageVolume) -> Self {
|
||||||
|
Self {
|
||||||
|
id: *volume.volume_id.as_uuid(),
|
||||||
|
volume_name: volume.volume_name.clone(),
|
||||||
|
uri_prefix: volume.uri_prefix.clone(),
|
||||||
|
is_writable: volume.is_writable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct LibraryPathResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub volume_id: Uuid,
|
||||||
|
pub relative_path: String,
|
||||||
|
pub is_ingest_destination: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LibraryPathResponse {
|
||||||
|
pub fn from_domain(path: &domain::entities::LibraryPath) -> Self {
|
||||||
|
Self {
|
||||||
|
id: *path.path_id.as_uuid(),
|
||||||
|
volume_id: *path.volume_id.as_uuid(),
|
||||||
|
relative_path: path.relative_path.clone(),
|
||||||
|
is_ingest_destination: path.is_ingest_destination,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct IngestResponse {
|
||||||
|
pub asset: AssetResponse,
|
||||||
|
pub session_id: Uuid,
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ adapters-postgres = { path = "../adapters/postgres" }
|
|||||||
|
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
dotenvy = { workspace = true }
|
dotenvy = { workspace = true }
|
||||||
|
|||||||
@@ -9,21 +9,32 @@ use tower_http::{
|
|||||||
|
|
||||||
use adapters_auth::{BcryptPasswordHasher, JwtTokenIssuer};
|
use adapters_auth::{BcryptPasswordHasher, JwtTokenIssuer};
|
||||||
|
|
||||||
use adapters_postgres::{PostgresUserRepository, connect, run_migrations};
|
use adapters_postgres::{
|
||||||
|
PostgresAlbumRepository, PostgresAssetMetadataRepository, PostgresAssetRepository,
|
||||||
|
PostgresIngestSessionRepository, PostgresLibraryPathRepository, PostgresQuotaRepository,
|
||||||
|
PostgresStorageVolumeRepository, PostgresUsageLedgerRepository, PostgresUserRepository,
|
||||||
|
connect, run_migrations,
|
||||||
|
};
|
||||||
|
|
||||||
use adapters_storage::{ObjectStorageAdapter, StorageConfig, build_store};
|
use adapters_storage::{LocalFileStorage, ObjectStorageAdapter, StorageConfig, build_store};
|
||||||
|
|
||||||
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 presentation::{routes::app_router, state::AppState};
|
use presentation::{routes::app_router, state::AppState};
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
use crate::log_event_publisher::LogEventPublisher;
|
||||||
|
|
||||||
pub async fn build_app(config: &Config) -> Result<Router> {
|
pub async fn build_app(config: &Config) -> Result<Router> {
|
||||||
let pool = connect(&config.database_url).await?;
|
let pool = connect(&config.database_url).await?;
|
||||||
run_migrations(&pool).await?;
|
run_migrations(&pool).await?;
|
||||||
|
|
||||||
let user_repo = Arc::new(PostgresUserRepository::new(pool));
|
// Identity
|
||||||
|
let user_repo = Arc::new(PostgresUserRepository::new(pool.clone()));
|
||||||
let hasher = Arc::new(BcryptPasswordHasher);
|
let hasher = Arc::new(BcryptPasswordHasher);
|
||||||
let issuer = Arc::new(JwtTokenIssuer::new(&config.jwt_secret));
|
let issuer = Arc::new(JwtTokenIssuer::new(&config.jwt_secret));
|
||||||
|
|
||||||
@@ -35,16 +46,75 @@ pub async fn build_app(config: &Config) -> Result<Router> {
|
|||||||
));
|
));
|
||||||
let get_profile_handler = Arc::new(GetProfileHandler::new(user_repo));
|
let get_profile_handler = Arc::new(GetProfileHandler::new(user_repo));
|
||||||
|
|
||||||
|
// Object storage
|
||||||
let storage_cfg = StorageConfig::from_env()?;
|
let storage_cfg = StorageConfig::from_env()?;
|
||||||
let store = build_store(&storage_cfg)?;
|
let store = build_store(&storage_cfg)?;
|
||||||
let storage = Arc::new(ObjectStorageAdapter::new(store, &storage_cfg.prefix)?);
|
let storage = Arc::new(ObjectStorageAdapter::new(store, &storage_cfg.prefix)?);
|
||||||
|
|
||||||
|
// Repos
|
||||||
|
let album_repo = Arc::new(PostgresAlbumRepository::new(pool.clone()));
|
||||||
|
let asset_repo = Arc::new(PostgresAssetRepository::new(pool.clone()));
|
||||||
|
let metadata_repo = Arc::new(PostgresAssetMetadataRepository::new(pool.clone()));
|
||||||
|
let volume_repo = Arc::new(PostgresStorageVolumeRepository::new(pool.clone()));
|
||||||
|
let path_repo = Arc::new(PostgresLibraryPathRepository::new(pool.clone()));
|
||||||
|
let session_repo = Arc::new(PostgresIngestSessionRepository::new(pool.clone()));
|
||||||
|
let quota_repo = Arc::new(PostgresQuotaRepository::new(pool.clone()));
|
||||||
|
let ledger_repo = Arc::new(PostgresUsageLedgerRepository::new(pool.clone()));
|
||||||
|
let event_publisher: Arc<LogEventPublisher> = Arc::new(LogEventPublisher);
|
||||||
|
|
||||||
|
// File storage for ingest
|
||||||
|
let storage_path = std::env::var("STORAGE_PATH").unwrap_or_else(|_| "./data/media".to_string());
|
||||||
|
let file_storage = Arc::new(LocalFileStorage::new(&storage_path));
|
||||||
|
|
||||||
|
// Album handlers
|
||||||
|
let create_album_handler = Arc::new(CreateAlbumHandler::new(album_repo.clone()));
|
||||||
|
let get_album_handler = Arc::new(GetAlbumHandler::new(album_repo.clone()));
|
||||||
|
let manage_album_entries_handler = Arc::new(ManageAlbumEntriesHandler::new(album_repo));
|
||||||
|
|
||||||
|
// Asset handlers
|
||||||
|
let ingest_asset_handler = Arc::new(IngestAssetHandler::new(
|
||||||
|
session_repo,
|
||||||
|
path_repo.clone(),
|
||||||
|
quota_repo,
|
||||||
|
ledger_repo,
|
||||||
|
asset_repo.clone(),
|
||||||
|
file_storage,
|
||||||
|
event_publisher.clone(),
|
||||||
|
));
|
||||||
|
let get_asset_handler = Arc::new(GetAssetHandler::new(
|
||||||
|
asset_repo.clone(),
|
||||||
|
metadata_repo.clone(),
|
||||||
|
));
|
||||||
|
let get_timeline_handler = Arc::new(GetTimelineHandler::new(
|
||||||
|
asset_repo.clone(),
|
||||||
|
metadata_repo.clone(),
|
||||||
|
));
|
||||||
|
let update_metadata_handler = Arc::new(UpdateMetadataHandler::new(
|
||||||
|
asset_repo,
|
||||||
|
metadata_repo,
|
||||||
|
event_publisher,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Storage handlers
|
||||||
|
let register_volume_handler = Arc::new(RegisterVolumeHandler::new(volume_repo.clone()));
|
||||||
|
let register_library_path_handler =
|
||||||
|
Arc::new(RegisterLibraryPathHandler::new(volume_repo, path_repo));
|
||||||
|
|
||||||
let state = AppState::new(
|
let state = AppState::new(
|
||||||
register_handler,
|
register_handler,
|
||||||
login_handler,
|
login_handler,
|
||||||
get_profile_handler,
|
get_profile_handler,
|
||||||
issuer,
|
issuer,
|
||||||
storage,
|
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,
|
||||||
);
|
);
|
||||||
|
|
||||||
let cors = CorsLayer::new()
|
let cors = CorsLayer::new()
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod factory;
|
||||||
|
pub mod log_event_publisher;
|
||||||
|
|||||||
12
crates/bootstrap/src/log_event_publisher.rs
Normal file
12
crates/bootstrap/src/log_event_publisher.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
|
||||||
|
|
||||||
|
pub struct LogEventPublisher;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl EventPublisher for LogEventPublisher {
|
||||||
|
async fn publish(&self, event: DomainEvent) -> Result<(), DomainError> {
|
||||||
|
tracing::info!(?event, "domain event published");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ use tracing::info;
|
|||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
mod factory;
|
mod factory;
|
||||||
|
mod log_event_publisher;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
@@ -20,8 +21,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
|
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
|
||||||
info!("🚀 Server running at http://{addr}");
|
info!("Server running at http://{addr}");
|
||||||
info!("📖 Scalar docs at http://{addr}/scalar");
|
info!("Scalar docs at http://{addr}/scalar");
|
||||||
|
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ edition = "2024"
|
|||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
application = { workspace = true }
|
application = { workspace = true }
|
||||||
api-types = { path = "../api-types" }
|
api-types = { path = "../api-types" }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true, features = ["multipart"] }
|
||||||
tower-http = { workspace = true }
|
tower-http = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
bytes = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
utoipa = { workspace = true }
|
utoipa = { workspace = true }
|
||||||
utoipa-scalar = { 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 auth;
|
||||||
pub mod health;
|
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::{
|
use crate::{
|
||||||
handlers::{auth, health},
|
handlers::{albums, assets, auth, health, storage},
|
||||||
openapi::openapi_router,
|
openapi::openapi_router,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
routing::{get, post},
|
routing::{delete, get, post, put},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn api_v1_router() -> Router<AppState> {
|
pub fn api_v1_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
// auth
|
||||||
.route("/auth/register", post(auth::register))
|
.route("/auth/register", post(auth::register))
|
||||||
.route("/auth/login", post(auth::login))
|
.route("/auth/login", post(auth::login))
|
||||||
.route("/auth/me", get(auth::me))
|
.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> {
|
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 std::sync::Arc;
|
||||||
|
|
||||||
use domain::ports::{StoragePort, TokenIssuer};
|
use domain::ports::{StoragePort, TokenIssuer};
|
||||||
@@ -10,15 +15,34 @@ pub struct AppState {
|
|||||||
pub get_profile_handler: Arc<GetProfileHandler>,
|
pub get_profile_handler: Arc<GetProfileHandler>,
|
||||||
pub token_issuer: Arc<dyn TokenIssuer>,
|
pub token_issuer: Arc<dyn TokenIssuer>,
|
||||||
pub storage: Arc<dyn StoragePort>,
|
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 {
|
impl AppState {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
register_handler: Arc<RegisterUserHandler>,
|
register_handler: Arc<RegisterUserHandler>,
|
||||||
login_handler: Arc<LoginUserHandler>,
|
login_handler: Arc<LoginUserHandler>,
|
||||||
get_profile_handler: Arc<GetProfileHandler>,
|
get_profile_handler: Arc<GetProfileHandler>,
|
||||||
token_issuer: Arc<dyn TokenIssuer>,
|
token_issuer: Arc<dyn TokenIssuer>,
|
||||||
storage: Arc<dyn StoragePort>,
|
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 {
|
||||||
Self {
|
Self {
|
||||||
register_handler,
|
register_handler,
|
||||||
@@ -26,6 +50,15 @@ impl AppState {
|
|||||||
get_profile_handler,
|
get_profile_handler,
|
||||||
token_issuer,
|
token_issuer,
|
||||||
storage,
|
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