feat: add presentation layer + bootstrap wiring for vertical slice

This commit is contained in:
2026-05-31 05:51:09 +02:00
parent 8c1a0e4519
commit 201eff717d
21 changed files with 726 additions and 51 deletions

View File

@@ -15,6 +15,7 @@ anyhow = { workspace = true }
tracing = { workspace = true }
bytes = { workspace = true }
futures = { workspace = true }
tokio = { workspace = true, features = ["fs"] }
object_store = { version = "0.11" }
[dev-dependencies]

View File

@@ -1,5 +1,7 @@
pub mod adapter;
pub mod config;
pub mod local_file_storage;
pub use adapter::ObjectStorageAdapter;
pub use config::{StorageConfig, build_store};
pub use local_file_storage::LocalFileStorage;

View 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)
}
}