feat: add presentation layer + bootstrap wiring for vertical slice
This commit is contained in:
@@ -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;
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user