completely refactor the backend
This commit is contained in:
677
backend/Cargo.lock
generated
677
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -12,11 +12,15 @@ name = "warren_backend"
|
|||||||
path = "src/bin/backend/main.rs"
|
path = "src/bin/backend/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
anyhow = "1.0.98"
|
||||||
axum = { version = "0.8.4", features = ["multipart", "query"] }
|
axum = { version = "0.8.4", features = ["multipart", "query"] }
|
||||||
|
axum_typed_multipart = "0.16.3"
|
||||||
|
derive_more = { version = "2.0.1", features = ["display"] }
|
||||||
dotenv = "0.15.0"
|
dotenv = "0.15.0"
|
||||||
env_logger = "0.11.8"
|
env_logger = "0.11.8"
|
||||||
log = "0.4.27"
|
log = "0.4.27"
|
||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
|
regex = "1.11.1"
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
serde_json = "1.0.140"
|
serde_json = "1.0.140"
|
||||||
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "uuid"] }
|
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "uuid"] }
|
||||||
|
|||||||
@@ -1,19 +1,51 @@
|
|||||||
use warren::{server, Result};
|
use warren::{
|
||||||
|
config::Config,
|
||||||
|
domain,
|
||||||
|
inbound::http::{HttpServer, HttpServerConfig},
|
||||||
|
outbound::{
|
||||||
|
file_system::{FileSystem, FileSystemConfig},
|
||||||
|
metrics_debug_logger::MetricsDebugLogger,
|
||||||
|
notifier_debug_logger::NotifierDebugLogger,
|
||||||
|
postgres::{Postgres, PostgresConfig},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
dotenv::dotenv().ok();
|
dotenv::dotenv().ok();
|
||||||
|
|
||||||
|
let config = Config::from_env()?;
|
||||||
|
|
||||||
env_logger::builder()
|
env_logger::builder()
|
||||||
.format_target(false)
|
.format_target(false)
|
||||||
.filter_level(log::LevelFilter::Info)
|
.filter_level(log::LevelFilter::Info)
|
||||||
.parse_env("LOG_LEVEL")
|
|
||||||
.parse_default_env()
|
.parse_default_env()
|
||||||
|
.filter_level(config.log_level)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let pool = warren::db::get_postgres_pool().await?;
|
let metrics = MetricsDebugLogger::new();
|
||||||
|
let notifier = NotifierDebugLogger::new();
|
||||||
|
|
||||||
server::start(pool).await?;
|
let postgres_config =
|
||||||
|
PostgresConfig::new(config.database_url.clone(), config.database_name.clone());
|
||||||
|
let postgres = Postgres::new(postgres_config).await?;
|
||||||
|
|
||||||
|
let fs_config = FileSystemConfig::new(config.serve_dir);
|
||||||
|
let fs = FileSystem::new(fs_config)?;
|
||||||
|
let fs_service = domain::file_system::service::Service::new(fs, metrics, notifier);
|
||||||
|
|
||||||
|
let warren_service =
|
||||||
|
domain::warren::service::Service::new(postgres, metrics, notifier, fs_service.clone());
|
||||||
|
|
||||||
|
let server_config = HttpServerConfig::new(
|
||||||
|
&config.server_address,
|
||||||
|
&config.server_port,
|
||||||
|
&config.cors_allow_origin,
|
||||||
|
config.static_frontend_dir.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let http_server = HttpServer::new(warren_service, server_config).await?;
|
||||||
|
http_server.run().await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
use crate::server::Router;
|
|
||||||
|
|
||||||
mod state;
|
|
||||||
mod warrens;
|
|
||||||
|
|
||||||
pub use state::AppState;
|
|
||||||
|
|
||||||
pub(super) fn router() -> Router {
|
|
||||||
Router::new().nest("/warrens", warrens::router())
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
use sqlx::{Pool, Postgres};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AppState {
|
|
||||||
pool: Pool<Postgres>,
|
|
||||||
serve_dir: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppState {
|
|
||||||
pub fn new(pool: Pool<Postgres>, serve_dir: String) -> Self {
|
|
||||||
Self { pool, serve_dir }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pool(&self) -> &Pool<Postgres> {
|
|
||||||
&self.pool
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn serve_dir(&self) -> &str {
|
|
||||||
&self.serve_dir
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
use axum::extract::{Path, State};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{Result, api::AppState, warrens::Warren};
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub(super) struct CreateWarrenDirectoryPath {
|
|
||||||
warren_id: Uuid,
|
|
||||||
rest: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn route(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(path): Path<CreateWarrenDirectoryPath>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let warren = Warren::get(state.pool(), &path.warren_id).await?;
|
|
||||||
|
|
||||||
warren
|
|
||||||
.create_directory(state.serve_dir(), path.rest)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
use axum::extract::{Path, Query, State};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{Result, api::AppState, fs::FileType, warrens::Warren};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub(super) struct DeleteWarrenDirectoryPath {
|
|
||||||
warren_id: Uuid,
|
|
||||||
rest: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct DeleteWarrenParams {
|
|
||||||
file_type: FileType,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn route(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(path): Path<DeleteWarrenDirectoryPath>,
|
|
||||||
Query(DeleteWarrenParams { file_type }): Query<DeleteWarrenParams>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let warren = Warren::get(state.pool(), &path.warren_id).await?;
|
|
||||||
|
|
||||||
warren
|
|
||||||
.delete_entry(state.serve_dir(), path.rest, file_type)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
use axum::Json;
|
|
||||||
use axum::extract::{Path, State};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::Result;
|
|
||||||
use crate::api::AppState;
|
|
||||||
use crate::warrens::Warren;
|
|
||||||
|
|
||||||
use crate::fs::DirectoryEntry;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub(super) struct WarrenRequestPath {
|
|
||||||
warren_id: Uuid,
|
|
||||||
rest: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn route(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(WarrenRequestPath { warren_id, rest }): Path<WarrenRequestPath>,
|
|
||||||
) -> Result<Json<Vec<DirectoryEntry>>> {
|
|
||||||
let warren = Warren::get(state.pool(), &warren_id).await?;
|
|
||||||
|
|
||||||
let entries = warren.read_path(state.serve_dir(), rest).await?;
|
|
||||||
|
|
||||||
Ok(Json(entries))
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
use axum::{Json, extract::State};
|
|
||||||
|
|
||||||
use crate::{Result, api::AppState, warrens::Warren};
|
|
||||||
|
|
||||||
pub(super) async fn route(State(state): State<AppState>) -> Result<Json<Vec<Warren>>> {
|
|
||||||
let warrens = Warren::list(state.pool()).await?;
|
|
||||||
|
|
||||||
Ok(Json(warrens))
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
mod create_directory;
|
|
||||||
mod delete_directory;
|
|
||||||
mod get_warren_path;
|
|
||||||
mod list_warrens;
|
|
||||||
mod upload_files;
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
extract::DefaultBodyLimit,
|
|
||||||
routing::{delete, get, post},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::server::Router;
|
|
||||||
|
|
||||||
pub(super) fn router() -> Router {
|
|
||||||
Router::new()
|
|
||||||
.route("/", get(list_warrens::route))
|
|
||||||
.route("/{warren_id}/get", get(get_warren_path::route))
|
|
||||||
.route("/{warren_id}/get/{*rest}", get(get_warren_path::route))
|
|
||||||
.route("/{warren_id}/create/{*rest}", post(create_directory::route))
|
|
||||||
.route(
|
|
||||||
"/{warren_id}/delete/{*rest}",
|
|
||||||
delete(delete_directory::route),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{warren_id}/upload",
|
|
||||||
// 536870912 bytes = 0.5GB
|
|
||||||
post(upload_files::route).route_layer(DefaultBodyLimit::max(536870912)),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/{warren_id}/upload/{*rest}",
|
|
||||||
// 536870912 bytes = 0.5GB
|
|
||||||
post(upload_files::route).route_layer(DefaultBodyLimit::max(536870912)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
use axum::extract::{Multipart, Path, State};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{Result, api::AppState, warrens::Warren};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub(super) struct UploadPath {
|
|
||||||
warren_id: Uuid,
|
|
||||||
rest: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn route(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(UploadPath { warren_id, rest }): Path<UploadPath>,
|
|
||||||
mut multipart: Multipart,
|
|
||||||
) -> Result<()> {
|
|
||||||
let warren = Warren::get(state.pool(), &warren_id).await?;
|
|
||||||
|
|
||||||
while let Ok(Some(field)) = multipart.next_field().await {
|
|
||||||
if field.name().is_none_or(|name| name != "files") {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let file_name = field.file_name().map(str::to_owned).unwrap();
|
|
||||||
let data = field.bytes().await?;
|
|
||||||
|
|
||||||
warren
|
|
||||||
.upload(state.serve_dir(), rest.as_deref(), &file_name, &data)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
68
backend/src/lib/config.rs
Normal file
68
backend/src/lib/config.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
use std::{env, str::FromStr as _};
|
||||||
|
|
||||||
|
use anyhow::Context as _;
|
||||||
|
use log::LevelFilter;
|
||||||
|
|
||||||
|
const DATABASE_URL_KEY: &str = "DATABASE_URL";
|
||||||
|
const DATABASE_NAME_KEY: &str = "DATABASE_NAME";
|
||||||
|
|
||||||
|
const SERVER_ADDRESS_KEY: &str = "SERVER_ADDRESS";
|
||||||
|
const SERVER_PORT_KEY: &str = "SERVER_PORT";
|
||||||
|
const CORS_ALLOW_ORIGIN_KEY: &str = "CORS_ALLOW_ORIGIN";
|
||||||
|
|
||||||
|
const SERVE_DIRECTORY_KEY: &str = "SERVE_DIRECTORY";
|
||||||
|
|
||||||
|
const STATIC_FRONTEND_DIRECTORY: &str = "STATIC_FRONTEND_DIRECTORY";
|
||||||
|
|
||||||
|
const LOG_LEVEL_KEY: &str = "LOG_LEVEL";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Config {
|
||||||
|
pub server_address: String,
|
||||||
|
pub server_port: u16,
|
||||||
|
pub cors_allow_origin: String,
|
||||||
|
|
||||||
|
pub serve_dir: String,
|
||||||
|
pub static_frontend_dir: Option<String>,
|
||||||
|
|
||||||
|
pub database_url: String,
|
||||||
|
pub database_name: String,
|
||||||
|
|
||||||
|
pub log_level: LevelFilter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_env() -> anyhow::Result<Self> {
|
||||||
|
let server_address = load_env(SERVER_ADDRESS_KEY)?;
|
||||||
|
let server_port = load_env(SERVER_PORT_KEY)?.parse()?;
|
||||||
|
let cors_allow_origin = load_env(CORS_ALLOW_ORIGIN_KEY)?;
|
||||||
|
|
||||||
|
let serve_dir = load_env(SERVE_DIRECTORY_KEY)?;
|
||||||
|
let static_frontend_dir = load_env(STATIC_FRONTEND_DIRECTORY).ok();
|
||||||
|
|
||||||
|
let database_url = load_env(DATABASE_URL_KEY)?;
|
||||||
|
let database_name = load_env(DATABASE_NAME_KEY)?;
|
||||||
|
|
||||||
|
let log_level =
|
||||||
|
LevelFilter::from_str(&load_env(LOG_LEVEL_KEY).unwrap_or("INFO".to_string()))
|
||||||
|
.context("Failed to convert the value of LOG_LEVEL to a LevelFilter")?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
server_address,
|
||||||
|
server_port,
|
||||||
|
cors_allow_origin,
|
||||||
|
|
||||||
|
serve_dir,
|
||||||
|
static_frontend_dir,
|
||||||
|
|
||||||
|
database_url,
|
||||||
|
database_name,
|
||||||
|
|
||||||
|
log_level,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_env(key: &str) -> anyhow::Result<String> {
|
||||||
|
env::var(key).context(format!("Failed to get environment variable: {key}"))
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
use std::env;
|
|
||||||
|
|
||||||
use sqlx::{Connection, PgConnection, Pool, Postgres, postgres::PgPoolOptions};
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
|
|
||||||
pub async fn get_postgres_pool() -> Result<Pool<Postgres>, AppError> {
|
|
||||||
let host = env::var("POSTGRES_HOST")?;
|
|
||||||
let port = env::var("POSTGRES_PORT")?;
|
|
||||||
let user = env::var("POSTGRES_USER")?;
|
|
||||||
let password = env::var("POSTGRES_PASSWORD")?;
|
|
||||||
let database = env::var("POSTGRES_DATABASE")?;
|
|
||||||
|
|
||||||
let base_url = format!("postgres://{user}:{password}@{host}:{port}");
|
|
||||||
|
|
||||||
let mut connection = PgConnection::connect(&base_url).await?;
|
|
||||||
let _ = sqlx::query(&format!("CREATE DATABASE {database}"))
|
|
||||||
.execute(&mut connection)
|
|
||||||
.await;
|
|
||||||
connection.close().await?;
|
|
||||||
|
|
||||||
let url = format!("{base_url}/{database}");
|
|
||||||
|
|
||||||
let pool = PgPoolOptions::new().connect(&url).await?;
|
|
||||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
|
||||||
|
|
||||||
Ok(pool)
|
|
||||||
}
|
|
||||||
3
backend/src/lib/domain/file_system/mod.rs
Normal file
3
backend/src/lib/domain/file_system/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod models;
|
||||||
|
pub mod ports;
|
||||||
|
pub mod service;
|
||||||
332
backend/src/lib/domain/file_system/models/file.rs
Normal file
332
backend/src/lib/domain/file_system/models/file.rs
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
use std::{fmt::Display, path::Path};
|
||||||
|
|
||||||
|
use derive_more::Display;
|
||||||
|
use serde::Serialize;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct File {
|
||||||
|
name: FileName,
|
||||||
|
file_type: FileType,
|
||||||
|
mime_type: Option<FileMimeType>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl File {
|
||||||
|
pub fn new(name: FileName, file_type: FileType, mime_type: Option<FileMimeType>) -> Self {
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
file_type,
|
||||||
|
mime_type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(&self) -> &FileName {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_type(&self) -> &FileType {
|
||||||
|
&self.file_type
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mime_type(&self) -> Option<&FileMimeType> {
|
||||||
|
self.mime_type.as_ref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A valid file name
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display)]
|
||||||
|
pub struct FileName(String);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error)]
|
||||||
|
pub enum FileNameError {
|
||||||
|
#[error("A file name must not contain a slash")]
|
||||||
|
Slash,
|
||||||
|
#[error("A file name must not be empty")]
|
||||||
|
Empty,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileName {
|
||||||
|
pub fn new(raw: &str) -> Result<Self, FileNameError> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(FileNameError::Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed.contains("/") {
|
||||||
|
return Err(FileNameError::Slash);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self(trimmed.to_owned()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A valid file type
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum FileType {
|
||||||
|
File,
|
||||||
|
Directory,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for FileType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
Self::File => "File",
|
||||||
|
Self::Directory => "Directory",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A valid file mime type
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display)]
|
||||||
|
pub struct FileMimeType(String);
|
||||||
|
|
||||||
|
impl FileMimeType {
|
||||||
|
pub fn from_name(name: &str) -> Option<Self> {
|
||||||
|
mime_guess::from_path(name)
|
||||||
|
.first_raw()
|
||||||
|
.map(|s| Self(s.to_owned()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A valid file path that might start with a slash
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display)]
|
||||||
|
pub struct FilePath(String);
|
||||||
|
|
||||||
|
/// A valid file path that does not start or end with a slash
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display)]
|
||||||
|
pub struct RelativeFilePath(String);
|
||||||
|
|
||||||
|
/// A valid file path that starts with a slash
|
||||||
|
/// In the context of this app absolute does not refer to the underlying file system but rather the
|
||||||
|
/// specified base directory
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display, sqlx::Type)]
|
||||||
|
#[sqlx(transparent)]
|
||||||
|
pub struct AbsoluteFilePath(String);
|
||||||
|
|
||||||
|
impl FilePath {
|
||||||
|
pub fn new(raw: &str) -> Result<FilePath, FilePathError> {
|
||||||
|
if raw.contains("//") || (raw.len() > 1 && raw.ends_with("/")) {
|
||||||
|
return Err(FilePathError::InvalidPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self(raw.to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn join(&self, other: &RelativeFilePath) -> Self {
|
||||||
|
let mut path = self.0.clone();
|
||||||
|
path.push('/');
|
||||||
|
path.push_str(&other.0);
|
||||||
|
|
||||||
|
Self(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, other: &RelativeFilePath) {
|
||||||
|
self.0.push('/');
|
||||||
|
self.0.push_str(&other.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<Path> for FilePath {
|
||||||
|
fn as_ref(&self) -> &Path {
|
||||||
|
self.0.as_ref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RelativeFilePath {
|
||||||
|
/* pub fn new(path: FilePath) -> Self {
|
||||||
|
Self(path.0.trim_start_matches('/').to_owned())
|
||||||
|
} */
|
||||||
|
|
||||||
|
pub fn join(mut self, other: &RelativeFilePath) -> Self {
|
||||||
|
self.0.push('/');
|
||||||
|
self.0.push_str(&other.0);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AbsoluteFilePath {
|
||||||
|
/// Just trims the leading slash
|
||||||
|
pub fn to_relative(mut self) -> RelativeFilePath {
|
||||||
|
self.0.remove(0);
|
||||||
|
|
||||||
|
RelativeFilePath(self.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_relative(&self) -> RelativeFilePath {
|
||||||
|
let mut path = self.0.clone();
|
||||||
|
path.remove(0);
|
||||||
|
|
||||||
|
RelativeFilePath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn join(mut self, other: &RelativeFilePath) -> Self {
|
||||||
|
if !other.0.is_empty() {
|
||||||
|
self.0.push('/');
|
||||||
|
self.0.push_str(&other.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error)]
|
||||||
|
pub enum FilePathError {
|
||||||
|
#[error("The provided path is invalid")]
|
||||||
|
InvalidPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error)]
|
||||||
|
pub enum RelativeFilePathError {
|
||||||
|
#[error("A relative file path must not with a slash")]
|
||||||
|
Absolute,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error)]
|
||||||
|
pub enum AbsoluteFilePathError {
|
||||||
|
#[error("An absolute file path must start with a slash")]
|
||||||
|
NotAbsolute,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryInto<AbsoluteFilePath> for FilePath {
|
||||||
|
type Error = AbsoluteFilePathError;
|
||||||
|
|
||||||
|
fn try_into(self) -> Result<AbsoluteFilePath, Self::Error> {
|
||||||
|
if !self.0.starts_with("/") {
|
||||||
|
return Err(AbsoluteFilePathError::NotAbsolute);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(AbsoluteFilePath(self.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryInto<RelativeFilePath> for FilePath {
|
||||||
|
type Error = RelativeFilePathError;
|
||||||
|
|
||||||
|
fn try_into(self) -> Result<RelativeFilePath, Self::Error> {
|
||||||
|
if self.0.starts_with("/") {
|
||||||
|
return Err(RelativeFilePathError::Absolute);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(RelativeFilePath(self.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AbsoluteFilePath> for FilePath {
|
||||||
|
fn from(value: AbsoluteFilePath) -> Self {
|
||||||
|
Self(value.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct ListFilesRequest {
|
||||||
|
path: AbsoluteFilePath,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListFilesRequest {
|
||||||
|
pub fn new(path: AbsoluteFilePath) -> Self {
|
||||||
|
Self { path }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &AbsoluteFilePath {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ListFilesError {
|
||||||
|
#[error("Directory at path {0} does not exist")]
|
||||||
|
NotFound(String),
|
||||||
|
#[error(transparent)]
|
||||||
|
Unknown(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct DeleteDirectoryRequest {
|
||||||
|
path: AbsoluteFilePath,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct DeleteFileRequest {
|
||||||
|
path: AbsoluteFilePath,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteDirectoryRequest {
|
||||||
|
pub fn new(path: AbsoluteFilePath) -> Self {
|
||||||
|
Self { path }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &AbsoluteFilePath {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteFileRequest {
|
||||||
|
pub fn new(path: AbsoluteFilePath) -> Self {
|
||||||
|
Self { path }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &AbsoluteFilePath {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum DeleteDirectoryError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Unknown(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum DeleteFileError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Unknown(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct CreateDirectoryRequest {
|
||||||
|
path: AbsoluteFilePath,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateDirectoryRequest {
|
||||||
|
pub fn new(path: AbsoluteFilePath) -> Self {
|
||||||
|
Self { path }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &AbsoluteFilePath {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum CreateDirectoryError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Unknown(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct CreateFileRequest {
|
||||||
|
path: AbsoluteFilePath,
|
||||||
|
data: Box<[u8]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateFileRequest {
|
||||||
|
pub fn new(path: AbsoluteFilePath, data: Box<[u8]>) -> Self {
|
||||||
|
Self { path, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &AbsoluteFilePath {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn data(&self) -> &[u8] {
|
||||||
|
&self.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum CreateFileError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Unknown(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
1
backend/src/lib/domain/file_system/models/mod.rs
Normal file
1
backend/src/lib/domain/file_system/models/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod file;
|
||||||
80
backend/src/lib/domain/file_system/ports.rs
Normal file
80
backend/src/lib/domain/file_system/ports.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
use super::models::file::{
|
||||||
|
CreateDirectoryError, CreateDirectoryRequest, CreateFileError, CreateFileRequest,
|
||||||
|
DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, File,
|
||||||
|
FilePath, ListFilesError, ListFilesRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub trait FileSystemService: Clone + Send + Sync + 'static {
|
||||||
|
fn list_files(
|
||||||
|
&self,
|
||||||
|
request: ListFilesRequest,
|
||||||
|
) -> impl Future<Output = Result<Vec<File>, ListFilesError>> + Send;
|
||||||
|
|
||||||
|
fn create_directory(
|
||||||
|
&self,
|
||||||
|
request: CreateDirectoryRequest,
|
||||||
|
) -> impl Future<Output = Result<FilePath, CreateDirectoryError>> + Send;
|
||||||
|
fn delete_directory(
|
||||||
|
&self,
|
||||||
|
request: DeleteDirectoryRequest,
|
||||||
|
) -> impl Future<Output = Result<FilePath, DeleteDirectoryError>> + Send;
|
||||||
|
|
||||||
|
fn create_file(
|
||||||
|
&self,
|
||||||
|
request: CreateFileRequest,
|
||||||
|
) -> impl Future<Output = Result<FilePath, CreateFileError>> + Send;
|
||||||
|
fn delete_file(
|
||||||
|
&self,
|
||||||
|
request: DeleteFileRequest,
|
||||||
|
) -> impl Future<Output = Result<FilePath, DeleteFileError>> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait FileSystemRepository: Clone + Send + Sync + 'static {
|
||||||
|
fn list_files(
|
||||||
|
&self,
|
||||||
|
request: ListFilesRequest,
|
||||||
|
) -> impl Future<Output = Result<Vec<File>, ListFilesError>> + Send;
|
||||||
|
|
||||||
|
fn create_directory(
|
||||||
|
&self,
|
||||||
|
request: CreateDirectoryRequest,
|
||||||
|
) -> impl Future<Output = Result<FilePath, CreateDirectoryError>> + Send;
|
||||||
|
fn delete_directory(
|
||||||
|
&self,
|
||||||
|
request: DeleteDirectoryRequest,
|
||||||
|
) -> impl Future<Output = Result<FilePath, DeleteDirectoryError>> + Send;
|
||||||
|
|
||||||
|
fn create_file(
|
||||||
|
&self,
|
||||||
|
request: CreateFileRequest,
|
||||||
|
) -> impl Future<Output = Result<FilePath, CreateFileError>> + Send;
|
||||||
|
fn delete_file(
|
||||||
|
&self,
|
||||||
|
request: DeleteFileRequest,
|
||||||
|
) -> impl Future<Output = Result<FilePath, DeleteFileError>> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait FileSystemMetrics: Clone + Send + Sync + 'static {
|
||||||
|
fn record_list_files_success(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
fn record_list_files_failure(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
|
||||||
|
fn record_directory_creation_success(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
fn record_directory_creation_failure(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
fn record_directory_deletion_success(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
fn record_directory_deletion_failure(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
|
||||||
|
fn record_file_creation_success(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
fn record_file_creation_failure(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
fn record_file_deletion_success(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
fn record_file_deletion_failure(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait FileSystemNotifier: Clone + Send + Sync + 'static {
|
||||||
|
fn files_listed(&self, files: &Vec<File>) -> impl Future<Output = ()> + Send;
|
||||||
|
|
||||||
|
fn directory_created(&self, path: &FilePath) -> impl Future<Output = ()> + Send;
|
||||||
|
fn directory_deleted(&self, path: &FilePath) -> impl Future<Output = ()> + Send;
|
||||||
|
|
||||||
|
fn file_created(&self, path: &FilePath) -> impl Future<Output = ()> + Send;
|
||||||
|
fn file_deleted(&self, path: &FilePath) -> impl Future<Output = ()> + Send;
|
||||||
|
}
|
||||||
116
backend/src/lib/domain/file_system/service.rs
Normal file
116
backend/src/lib/domain/file_system/service.rs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
use super::{
|
||||||
|
models::file::{
|
||||||
|
CreateDirectoryError, CreateDirectoryRequest, CreateFileError, CreateFileRequest,
|
||||||
|
DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, FilePath,
|
||||||
|
ListFilesError, ListFilesRequest,
|
||||||
|
},
|
||||||
|
ports::{FileSystemMetrics, FileSystemNotifier, FileSystemRepository, FileSystemService},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Service<R, M, N>
|
||||||
|
where
|
||||||
|
R: FileSystemRepository,
|
||||||
|
M: FileSystemMetrics,
|
||||||
|
N: FileSystemNotifier,
|
||||||
|
{
|
||||||
|
repository: R,
|
||||||
|
metrics: M,
|
||||||
|
notifier: N,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R, M, N> Service<R, M, N>
|
||||||
|
where
|
||||||
|
R: FileSystemRepository,
|
||||||
|
M: FileSystemMetrics,
|
||||||
|
N: FileSystemNotifier,
|
||||||
|
{
|
||||||
|
pub fn new(repository: R, metrics: M, notifier: N) -> Self {
|
||||||
|
Self {
|
||||||
|
repository,
|
||||||
|
metrics,
|
||||||
|
notifier,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R, M, N> FileSystemService for Service<R, M, N>
|
||||||
|
where
|
||||||
|
R: FileSystemRepository,
|
||||||
|
M: FileSystemMetrics,
|
||||||
|
N: FileSystemNotifier,
|
||||||
|
{
|
||||||
|
async fn list_files(
|
||||||
|
&self,
|
||||||
|
request: ListFilesRequest,
|
||||||
|
) -> Result<Vec<super::models::file::File>, ListFilesError> {
|
||||||
|
let result = self.repository.list_files(request).await;
|
||||||
|
|
||||||
|
if let Ok(files) = result.as_ref() {
|
||||||
|
self.metrics.record_list_files_success().await;
|
||||||
|
self.notifier.files_listed(files).await;
|
||||||
|
} else {
|
||||||
|
self.metrics.record_list_files_failure().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_directory(
|
||||||
|
&self,
|
||||||
|
request: CreateDirectoryRequest,
|
||||||
|
) -> Result<FilePath, CreateDirectoryError> {
|
||||||
|
let result = self.repository.create_directory(request).await;
|
||||||
|
|
||||||
|
if let Ok(path) = result.as_ref() {
|
||||||
|
self.metrics.record_directory_creation_success().await;
|
||||||
|
self.notifier.directory_created(path).await;
|
||||||
|
} else {
|
||||||
|
self.metrics.record_directory_creation_failure().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_directory(
|
||||||
|
&self,
|
||||||
|
request: DeleteDirectoryRequest,
|
||||||
|
) -> Result<FilePath, DeleteDirectoryError> {
|
||||||
|
let result = self.repository.delete_directory(request).await;
|
||||||
|
|
||||||
|
if let Ok(path) = result.as_ref() {
|
||||||
|
self.metrics.record_directory_deletion_success().await;
|
||||||
|
self.notifier.directory_deleted(path).await;
|
||||||
|
} else {
|
||||||
|
self.metrics.record_directory_deletion_failure().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_file(&self, request: CreateFileRequest) -> Result<FilePath, CreateFileError> {
|
||||||
|
let result = self.repository.create_file(request).await;
|
||||||
|
|
||||||
|
if let Ok(path) = result.as_ref() {
|
||||||
|
self.metrics.record_file_creation_success().await;
|
||||||
|
self.notifier.file_created(path).await;
|
||||||
|
} else {
|
||||||
|
self.metrics.record_file_creation_failure().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_file(&self, request: DeleteFileRequest) -> Result<FilePath, DeleteFileError> {
|
||||||
|
let result = self.repository.delete_file(request).await;
|
||||||
|
|
||||||
|
if let Ok(path) = result.as_ref() {
|
||||||
|
self.metrics.record_file_deletion_success().await;
|
||||||
|
self.notifier.file_deleted(path).await;
|
||||||
|
} else {
|
||||||
|
self.metrics.record_file_deletion_failure().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
2
backend/src/lib/domain/mod.rs
Normal file
2
backend/src/lib/domain/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod file_system;
|
||||||
|
pub mod warren;
|
||||||
3
backend/src/lib/domain/warren/mod.rs
Normal file
3
backend/src/lib/domain/warren/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod models;
|
||||||
|
pub mod ports;
|
||||||
|
pub mod service;
|
||||||
1
backend/src/lib/domain/warren/models/mod.rs
Normal file
1
backend/src/lib/domain/warren/models/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod warren;
|
||||||
359
backend/src/lib/domain/warren/models/warren.rs
Normal file
359
backend/src/lib/domain/warren/models/warren.rs
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
use derive_more::Display;
|
||||||
|
use thiserror::Error;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::domain::file_system::models::file::{
|
||||||
|
AbsoluteFilePath, CreateDirectoryError, CreateDirectoryRequest, CreateFileError,
|
||||||
|
CreateFileRequest, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError,
|
||||||
|
DeleteFileRequest, FileName, FilePath, ListFilesError, ListFilesRequest, RelativeFilePath,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, sqlx::FromRow)]
|
||||||
|
pub struct Warren {
|
||||||
|
id: Uuid,
|
||||||
|
name: WarrenName,
|
||||||
|
path: AbsoluteFilePath,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Warren {
|
||||||
|
pub fn new(id: Uuid, name: WarrenName, path: AbsoluteFilePath) -> Self {
|
||||||
|
Self { id, name, path }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id(&self) -> &Uuid {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(&self) -> &WarrenName {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &AbsoluteFilePath {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A valid warren name
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display, sqlx::Type)]
|
||||||
|
#[sqlx(transparent)]
|
||||||
|
pub struct WarrenName(String);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error)]
|
||||||
|
pub enum WarrenNameError {
|
||||||
|
#[error("A warren name must not be empty")]
|
||||||
|
Empty,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WarrenName {
|
||||||
|
pub fn new(raw: &str) -> Result<Self, WarrenNameError> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(WarrenNameError::Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self(trimmed.to_owned()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct FetchWarrenRequest {
|
||||||
|
id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FetchWarrenRequest {
|
||||||
|
pub fn new(id: Uuid) -> Self {
|
||||||
|
Self { id }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id(&self) -> &Uuid {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum FetchWarrenError {
|
||||||
|
#[error("Warren with id {0} does not exist")]
|
||||||
|
NotFound(Uuid),
|
||||||
|
#[error(transparent)]
|
||||||
|
Unknown(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct ListWarrensRequest;
|
||||||
|
|
||||||
|
impl ListWarrensRequest {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ListWarrensError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Unknown(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct ListWarrenFilesRequest {
|
||||||
|
warren_id: Uuid,
|
||||||
|
path: AbsoluteFilePath,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListWarrenFilesRequest {
|
||||||
|
pub fn new(warren_id: Uuid, path: AbsoluteFilePath) -> Self {
|
||||||
|
Self { warren_id, path }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn warren_id(&self) -> &Uuid {
|
||||||
|
&self.warren_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &AbsoluteFilePath {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Into<FetchWarrenRequest> for ListWarrenFilesRequest {
|
||||||
|
fn into(self) -> FetchWarrenRequest {
|
||||||
|
FetchWarrenRequest::new(self.warren_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListWarrenFilesRequest {
|
||||||
|
pub fn to_fs_request(self, warren: &Warren) -> ListFilesRequest {
|
||||||
|
let path = warren.path().clone().join(&self.path.to_relative());
|
||||||
|
ListFilesRequest::new(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ListWarrenFilesError {
|
||||||
|
#[error(transparent)]
|
||||||
|
FileSystem(#[from] ListFilesError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Unknown(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct CreateWarrenDirectoryRequest {
|
||||||
|
warren_id: Uuid,
|
||||||
|
path: AbsoluteFilePath,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateWarrenDirectoryRequest {
|
||||||
|
pub fn new(warren_id: Uuid, path: AbsoluteFilePath) -> Self {
|
||||||
|
Self { warren_id, path }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn warren_id(&self) -> &Uuid {
|
||||||
|
&self.warren_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &AbsoluteFilePath {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Into<FetchWarrenRequest> for CreateWarrenDirectoryRequest {
|
||||||
|
fn into(self) -> FetchWarrenRequest {
|
||||||
|
FetchWarrenRequest::new(self.warren_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateWarrenDirectoryRequest {
|
||||||
|
pub fn to_fs_request(self, warren: &Warren) -> CreateDirectoryRequest {
|
||||||
|
let path = warren.path().clone().join(&self.path.to_relative());
|
||||||
|
CreateDirectoryRequest::new(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum CreateWarrenDirectoryError {
|
||||||
|
#[error(transparent)]
|
||||||
|
FileSystem(#[from] CreateDirectoryError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Unknown(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct DeleteWarrenDirectoryRequest {
|
||||||
|
warren_id: Uuid,
|
||||||
|
path: AbsoluteFilePath,
|
||||||
|
force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteWarrenDirectoryRequest {
|
||||||
|
pub fn new(warren_id: Uuid, path: AbsoluteFilePath, force: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
warren_id,
|
||||||
|
path,
|
||||||
|
force,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_fs_request(self, warren: &Warren) -> DeleteDirectoryRequest {
|
||||||
|
let path = warren.path().clone().join(&self.path.to_relative());
|
||||||
|
DeleteDirectoryRequest::new(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn warren_id(&self) -> &Uuid {
|
||||||
|
&self.warren_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &AbsoluteFilePath {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn force(&self) -> bool {
|
||||||
|
self.force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Into<FetchWarrenRequest> for &DeleteWarrenDirectoryRequest {
|
||||||
|
fn into(self) -> FetchWarrenRequest {
|
||||||
|
FetchWarrenRequest::new(self.warren_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum DeleteWarrenDirectoryError {
|
||||||
|
#[error(transparent)]
|
||||||
|
FileSystem(#[from] DeleteDirectoryError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Unknown(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct DeleteWarrenFileRequest {
|
||||||
|
warren_id: Uuid,
|
||||||
|
path: AbsoluteFilePath,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteWarrenFileRequest {
|
||||||
|
pub fn new(warren_id: Uuid, path: AbsoluteFilePath) -> Self {
|
||||||
|
Self { warren_id, path }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_fs_request(self, warren: &Warren) -> DeleteFileRequest {
|
||||||
|
let path = warren.path().clone().join(&self.path.to_relative());
|
||||||
|
DeleteFileRequest::new(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn warren_id(&self) -> &Uuid {
|
||||||
|
&self.warren_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &AbsoluteFilePath {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Into<FetchWarrenRequest> for &DeleteWarrenFileRequest {
|
||||||
|
fn into(self) -> FetchWarrenRequest {
|
||||||
|
FetchWarrenRequest::new(self.warren_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum DeleteWarrenFileError {
|
||||||
|
#[error(transparent)]
|
||||||
|
FileSystem(#[from] DeleteFileError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Unknown(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct UploadWarrenFilesRequest {
|
||||||
|
warren_id: Uuid,
|
||||||
|
path: AbsoluteFilePath,
|
||||||
|
files: UploadFileList,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UploadWarrenFilesRequest {
|
||||||
|
pub fn new(warren_id: Uuid, path: AbsoluteFilePath, files: UploadFileList) -> Self {
|
||||||
|
Self {
|
||||||
|
warren_id,
|
||||||
|
path,
|
||||||
|
files,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn warren_id(&self) -> &Uuid {
|
||||||
|
&self.warren_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_fs_requests(self, warren: &Warren) -> Vec<CreateFileRequest> {
|
||||||
|
let base_upload_path = self.path.as_relative();
|
||||||
|
|
||||||
|
self.files
|
||||||
|
.0
|
||||||
|
.into_iter()
|
||||||
|
.map(|f| {
|
||||||
|
let file_name = FilePath::new(&f.file_name.to_string()).unwrap();
|
||||||
|
let relative_file_path: RelativeFilePath = file_name.try_into().unwrap();
|
||||||
|
let absolute_file_path = warren
|
||||||
|
.path()
|
||||||
|
.clone()
|
||||||
|
.join(&base_upload_path)
|
||||||
|
.join(&relative_file_path);
|
||||||
|
CreateFileRequest::new(absolute_file_path, f.data)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Into<FetchWarrenRequest> for &UploadWarrenFilesRequest {
|
||||||
|
fn into(self) -> FetchWarrenRequest {
|
||||||
|
FetchWarrenRequest::new(self.warren_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum UploadWarrenFilesError {
|
||||||
|
#[error("Failed to upload the file at index {fail_index}")]
|
||||||
|
Partial { fail_index: usize },
|
||||||
|
#[error(transparent)]
|
||||||
|
FileSystem(#[from] CreateFileError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Unknown(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct UploadFileList(Vec<UploadFile>);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Error)]
|
||||||
|
pub enum UploadFileListError {
|
||||||
|
#[error("The file list must not be empty")]
|
||||||
|
Empty,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UploadFileList {
|
||||||
|
pub fn new(files: Vec<UploadFile>) -> Result<Self, UploadFileListError> {
|
||||||
|
if files.len() < 1 {
|
||||||
|
return Err(UploadFileListError::Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct UploadFile {
|
||||||
|
file_name: FileName,
|
||||||
|
data: Box<[u8]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UploadFile {
|
||||||
|
pub fn new(file_name: FileName, data: Box<[u8]>) -> Self {
|
||||||
|
Self { file_name, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_name(&self) -> &FileName {
|
||||||
|
&self.file_name
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn data(&self) -> &[u8] {
|
||||||
|
&self.data
|
||||||
|
}
|
||||||
|
}
|
||||||
129
backend/src/lib/domain/warren/ports.rs
Normal file
129
backend/src/lib/domain/warren/ports.rs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
use crate::domain::file_system::models::file::{File, FilePath};
|
||||||
|
|
||||||
|
use super::models::warren::{
|
||||||
|
CreateWarrenDirectoryError, CreateWarrenDirectoryRequest, DeleteWarrenDirectoryError,
|
||||||
|
DeleteWarrenDirectoryRequest, DeleteWarrenFileError, DeleteWarrenFileRequest, FetchWarrenError,
|
||||||
|
FetchWarrenRequest, ListWarrenFilesError, ListWarrenFilesRequest, ListWarrensError,
|
||||||
|
ListWarrensRequest, UploadWarrenFilesError, UploadWarrenFilesRequest, Warren,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub trait WarrenService: Clone + Send + Sync + 'static {
|
||||||
|
fn list_warrens(
|
||||||
|
&self,
|
||||||
|
request: ListWarrensRequest,
|
||||||
|
) -> impl Future<Output = Result<Vec<Warren>, ListWarrensError>> + Send;
|
||||||
|
|
||||||
|
fn fetch_warren(
|
||||||
|
&self,
|
||||||
|
request: FetchWarrenRequest,
|
||||||
|
) -> impl Future<Output = Result<Warren, FetchWarrenError>> + Send;
|
||||||
|
|
||||||
|
fn list_files(
|
||||||
|
&self,
|
||||||
|
request: ListWarrenFilesRequest,
|
||||||
|
) -> impl Future<Output = Result<Vec<File>, ListWarrenFilesError>> + Send;
|
||||||
|
|
||||||
|
fn create_warren_directory(
|
||||||
|
&self,
|
||||||
|
request: CreateWarrenDirectoryRequest,
|
||||||
|
) -> impl Future<Output = Result<FilePath, CreateWarrenDirectoryError>> + Send;
|
||||||
|
|
||||||
|
fn delete_warren_directory(
|
||||||
|
&self,
|
||||||
|
request: DeleteWarrenDirectoryRequest,
|
||||||
|
) -> impl Future<Output = Result<FilePath, DeleteWarrenDirectoryError>> + Send;
|
||||||
|
|
||||||
|
fn upload_warren_files(
|
||||||
|
&self,
|
||||||
|
request: UploadWarrenFilesRequest,
|
||||||
|
) -> impl Future<Output = Result<Vec<FilePath>, UploadWarrenFilesError>> + Send;
|
||||||
|
fn delete_warren_file(
|
||||||
|
&self,
|
||||||
|
request: DeleteWarrenFileRequest,
|
||||||
|
) -> impl Future<Output = Result<FilePath, DeleteWarrenFileError>> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait WarrenRepository: Clone + Send + Sync + 'static {
|
||||||
|
fn list_warrens(
|
||||||
|
&self,
|
||||||
|
request: ListWarrensRequest,
|
||||||
|
) -> impl Future<Output = Result<Vec<Warren>, ListWarrensError>> + Send;
|
||||||
|
|
||||||
|
fn fetch_warren(
|
||||||
|
&self,
|
||||||
|
request: FetchWarrenRequest,
|
||||||
|
) -> impl Future<Output = Result<Warren, FetchWarrenError>> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait WarrenMetrics: Clone + Send + Sync + 'static {
|
||||||
|
fn record_warren_list_success(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
fn record_warren_list_failure(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
|
||||||
|
fn record_warren_fetch_success(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
fn record_warren_fetch_failure(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
|
||||||
|
fn record_list_warren_files_success(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
fn record_list_warren_files_failure(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
|
||||||
|
fn record_warren_directory_creation_success(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
fn record_warren_directory_creation_failure(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
|
||||||
|
fn record_warren_directory_deletion_success(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
fn record_warren_directory_deletion_failure(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
|
||||||
|
/// A single file upload succeeded
|
||||||
|
fn record_warren_file_upload_success(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
/// A single file upload failed
|
||||||
|
fn record_warren_file_upload_failure(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
|
||||||
|
/// An upload succeeded fully
|
||||||
|
fn record_warren_files_upload_success(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
/// An upload failed at least partially
|
||||||
|
fn record_warren_files_upload_failure(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
|
||||||
|
fn record_warren_file_deletion_success(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
fn record_warren_file_deletion_failure(&self) -> impl Future<Output = ()> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait WarrenNotifier: Clone + Send + Sync + 'static {
|
||||||
|
fn warrens_listed(&self, warrens: &Vec<Warren>) -> impl Future<Output = ()> + Send;
|
||||||
|
fn warren_fetched(&self, warren: &Warren) -> impl Future<Output = ()> + Send;
|
||||||
|
fn warren_files_listed(
|
||||||
|
&self,
|
||||||
|
warren: &Warren,
|
||||||
|
files: &Vec<File>,
|
||||||
|
) -> impl Future<Output = ()> + Send;
|
||||||
|
fn warren_directory_created(
|
||||||
|
&self,
|
||||||
|
warren: &Warren,
|
||||||
|
path: &FilePath,
|
||||||
|
) -> impl Future<Output = ()> + Send;
|
||||||
|
fn warren_directory_deleted(
|
||||||
|
&self,
|
||||||
|
warren: &Warren,
|
||||||
|
path: &FilePath,
|
||||||
|
) -> impl Future<Output = ()> + Send;
|
||||||
|
/// A single file was uploaded
|
||||||
|
///
|
||||||
|
/// * `warren`: The warren the file was uploaded to
|
||||||
|
/// * `path`: The file's path
|
||||||
|
fn warren_file_uploaded(
|
||||||
|
&self,
|
||||||
|
warren: &Warren,
|
||||||
|
path: &FilePath,
|
||||||
|
) -> impl Future<Output = ()> + Send;
|
||||||
|
/// A collection of files was uploaded
|
||||||
|
///
|
||||||
|
/// * `warren`: The warren the file was uploaded to
|
||||||
|
/// * `files`: The files' paths
|
||||||
|
fn warren_files_uploaded(
|
||||||
|
&self,
|
||||||
|
warren: &Warren,
|
||||||
|
files: &[FilePath],
|
||||||
|
) -> impl Future<Output = ()> + Send;
|
||||||
|
fn warren_file_deleted(
|
||||||
|
&self,
|
||||||
|
warren: &Warren,
|
||||||
|
path: &FilePath,
|
||||||
|
) -> impl Future<Output = ()> + Send;
|
||||||
|
}
|
||||||
223
backend/src/lib/domain/warren/service.rs
Normal file
223
backend/src/lib/domain/warren/service.rs
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
use anyhow::Context;
|
||||||
|
|
||||||
|
use crate::domain::file_system::{
|
||||||
|
models::file::{File, FilePath},
|
||||||
|
ports::FileSystemService,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
models::warren::{
|
||||||
|
CreateWarrenDirectoryError, CreateWarrenDirectoryRequest, DeleteWarrenDirectoryError,
|
||||||
|
DeleteWarrenDirectoryRequest, DeleteWarrenFileError, DeleteWarrenFileRequest,
|
||||||
|
FetchWarrenError, FetchWarrenRequest, ListWarrenFilesError, ListWarrenFilesRequest,
|
||||||
|
UploadWarrenFilesError, UploadWarrenFilesRequest, Warren,
|
||||||
|
},
|
||||||
|
ports::{WarrenMetrics, WarrenNotifier, WarrenRepository, WarrenService},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Service<R, M, N, FSS>
|
||||||
|
where
|
||||||
|
R: WarrenRepository,
|
||||||
|
M: WarrenMetrics,
|
||||||
|
N: WarrenNotifier,
|
||||||
|
FSS: FileSystemService,
|
||||||
|
{
|
||||||
|
repository: R,
|
||||||
|
metrics: M,
|
||||||
|
notifier: N,
|
||||||
|
fs_service: FSS,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R, M, N, FSS> Service<R, M, N, FSS>
|
||||||
|
where
|
||||||
|
R: WarrenRepository,
|
||||||
|
M: WarrenMetrics,
|
||||||
|
N: WarrenNotifier,
|
||||||
|
FSS: FileSystemService,
|
||||||
|
{
|
||||||
|
pub fn new(repository: R, metrics: M, notifier: N, fs_service: FSS) -> Self {
|
||||||
|
Self {
|
||||||
|
repository,
|
||||||
|
metrics,
|
||||||
|
notifier,
|
||||||
|
fs_service,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R, M, N, FSS> WarrenService for Service<R, M, N, FSS>
|
||||||
|
where
|
||||||
|
R: WarrenRepository,
|
||||||
|
M: WarrenMetrics,
|
||||||
|
N: WarrenNotifier,
|
||||||
|
FSS: FileSystemService,
|
||||||
|
{
|
||||||
|
async fn list_warrens(
|
||||||
|
&self,
|
||||||
|
request: super::models::warren::ListWarrensRequest,
|
||||||
|
) -> Result<Vec<Warren>, super::models::warren::ListWarrensError> {
|
||||||
|
let result = self.repository.list_warrens(request).await;
|
||||||
|
|
||||||
|
if let Ok(warren) = result.as_ref() {
|
||||||
|
self.metrics.record_warren_list_success().await;
|
||||||
|
self.notifier.warrens_listed(warren).await;
|
||||||
|
} else {
|
||||||
|
self.metrics.record_warren_list_failure().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_warren(&self, request: FetchWarrenRequest) -> Result<Warren, FetchWarrenError> {
|
||||||
|
let result = self.repository.fetch_warren(request).await;
|
||||||
|
|
||||||
|
if let Ok(warren) = result.as_ref() {
|
||||||
|
self.metrics.record_warren_fetch_success().await;
|
||||||
|
self.notifier.warren_fetched(warren).await;
|
||||||
|
} else {
|
||||||
|
self.metrics.record_warren_fetch_failure().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_files(
|
||||||
|
&self,
|
||||||
|
request: ListWarrenFilesRequest,
|
||||||
|
) -> Result<Vec<File>, ListWarrenFilesError> {
|
||||||
|
let warren = self
|
||||||
|
.repository
|
||||||
|
.fetch_warren(request.clone().into())
|
||||||
|
.await
|
||||||
|
.context("Failed to fetch warren")?;
|
||||||
|
|
||||||
|
let result = self
|
||||||
|
.fs_service
|
||||||
|
.list_files(request.to_fs_request(&warren))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Ok(files) = result.as_ref() {
|
||||||
|
self.metrics.record_list_warren_files_success().await;
|
||||||
|
self.notifier.warren_files_listed(&warren, files).await;
|
||||||
|
} else {
|
||||||
|
self.metrics.record_list_warren_files_failure().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_warren_directory(
|
||||||
|
&self,
|
||||||
|
request: CreateWarrenDirectoryRequest,
|
||||||
|
) -> Result<FilePath, CreateWarrenDirectoryError> {
|
||||||
|
let warren = self
|
||||||
|
.repository
|
||||||
|
.fetch_warren(request.clone().into())
|
||||||
|
.await
|
||||||
|
.context("Failed to fetch warren")?;
|
||||||
|
|
||||||
|
let result = self
|
||||||
|
.fs_service
|
||||||
|
.create_directory(request.to_fs_request(&warren))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Ok(path) = result.as_ref() {
|
||||||
|
self.metrics
|
||||||
|
.record_warren_directory_creation_success()
|
||||||
|
.await;
|
||||||
|
self.notifier.warren_directory_created(&warren, path).await;
|
||||||
|
} else {
|
||||||
|
self.metrics
|
||||||
|
.record_warren_directory_creation_failure()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_warren_directory(
|
||||||
|
&self,
|
||||||
|
request: DeleteWarrenDirectoryRequest,
|
||||||
|
) -> Result<FilePath, DeleteWarrenDirectoryError> {
|
||||||
|
let warren = self
|
||||||
|
.repository
|
||||||
|
.fetch_warren((&request).into())
|
||||||
|
.await
|
||||||
|
.context("Failed to fetch warren")?;
|
||||||
|
|
||||||
|
let result = self
|
||||||
|
.fs_service
|
||||||
|
.delete_directory(request.to_fs_request(&warren))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Ok(path) = result.as_ref() {
|
||||||
|
self.metrics
|
||||||
|
.record_warren_directory_deletion_success()
|
||||||
|
.await;
|
||||||
|
self.notifier.warren_directory_deleted(&warren, path).await;
|
||||||
|
} else {
|
||||||
|
self.metrics
|
||||||
|
.record_warren_directory_deletion_failure()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Improve this
|
||||||
|
async fn upload_warren_files(
|
||||||
|
&self,
|
||||||
|
request: UploadWarrenFilesRequest,
|
||||||
|
) -> Result<Vec<FilePath>, UploadWarrenFilesError> {
|
||||||
|
let warren = self
|
||||||
|
.repository
|
||||||
|
.fetch_warren((&request).into())
|
||||||
|
.await
|
||||||
|
.context("Failed to fetch warren")?;
|
||||||
|
|
||||||
|
let fs_requests = request.to_fs_requests(&warren);
|
||||||
|
|
||||||
|
let mut paths = Vec::with_capacity(fs_requests.len());
|
||||||
|
|
||||||
|
for (i, req) in fs_requests.into_iter().enumerate() {
|
||||||
|
let result = self.fs_service.create_file(req).await;
|
||||||
|
|
||||||
|
let Ok(file_path) = result else {
|
||||||
|
self.metrics.record_warren_file_upload_failure().await;
|
||||||
|
self.metrics.record_warren_files_upload_failure().await;
|
||||||
|
return Err(UploadWarrenFilesError::Partial { fail_index: i });
|
||||||
|
};
|
||||||
|
|
||||||
|
self.metrics.record_warren_file_upload_success().await;
|
||||||
|
self.notifier
|
||||||
|
.warren_file_uploaded(&warren, &file_path)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
paths.push(file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.metrics.record_warren_files_upload_success().await;
|
||||||
|
self.notifier.warren_files_uploaded(&warren, &paths).await;
|
||||||
|
|
||||||
|
Ok(paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_warren_file(
|
||||||
|
&self,
|
||||||
|
request: DeleteWarrenFileRequest,
|
||||||
|
) -> Result<FilePath, DeleteWarrenFileError> {
|
||||||
|
let warren = self
|
||||||
|
.repository
|
||||||
|
.fetch_warren((&request).into())
|
||||||
|
.await
|
||||||
|
.context("Failed to fetch warren")?;
|
||||||
|
|
||||||
|
let result = self
|
||||||
|
.fs_service
|
||||||
|
.delete_file(request.to_fs_request(&warren))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
result.map_err(Into::into)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
body::Body,
|
|
||||||
extract::multipart::MultipartError,
|
|
||||||
http::{StatusCode, header::InvalidHeaderValue},
|
|
||||||
response::{IntoResponse, Response},
|
|
||||||
};
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum AppError {
|
|
||||||
#[error("IO: {0}")]
|
|
||||||
Io(#[from] std::io::Error),
|
|
||||||
#[error("Could not get file name")]
|
|
||||||
FileName,
|
|
||||||
#[error("SQLx: {0}")]
|
|
||||||
Sqlx(#[from] sqlx::Error),
|
|
||||||
#[error("Failed to migrate the database")]
|
|
||||||
DatabaseMigration(#[from] sqlx::migrate::MigrateError),
|
|
||||||
#[error("Var: {0}")]
|
|
||||||
Var(#[from] std::env::VarError),
|
|
||||||
#[error("InvalidHeaderValue: {0}")]
|
|
||||||
InvalidHeaderValue(#[from] InvalidHeaderValue),
|
|
||||||
#[error("Multipart: {0}")]
|
|
||||||
Multipart(#[from] MultipartError),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoResponse for AppError {
|
|
||||||
fn into_response(self) -> Response {
|
|
||||||
let status = match self {
|
|
||||||
Self::Io(error) => match error.kind() {
|
|
||||||
std::io::ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
|
||||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
},
|
|
||||||
Self::FileName => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
// TODO: Improve
|
|
||||||
Self::Sqlx(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Self::DatabaseMigration(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Self::Var(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Self::InvalidHeaderValue(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Self::Multipart(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
};
|
|
||||||
|
|
||||||
Response::builder()
|
|
||||||
.status(status)
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use tokio::fs;
|
|
||||||
|
|
||||||
use crate::{Result, error::AppError};
|
|
||||||
|
|
||||||
use super::{DirectoryEntry, FileType};
|
|
||||||
|
|
||||||
pub async fn get_dir_entries<P>(path: P) -> Result<Vec<DirectoryEntry>>
|
|
||||||
where
|
|
||||||
P: AsRef<Path>,
|
|
||||||
{
|
|
||||||
let mut dir = fs::read_dir(path).await?;
|
|
||||||
|
|
||||||
let mut files = Vec::new();
|
|
||||||
|
|
||||||
while let Ok(Some(entry)) = dir.next_entry().await {
|
|
||||||
let name = entry
|
|
||||||
.file_name()
|
|
||||||
.into_string()
|
|
||||||
.map_err(|_| AppError::FileName)?;
|
|
||||||
let file_type = {
|
|
||||||
let file_type = entry.file_type().await?;
|
|
||||||
|
|
||||||
if file_type.is_dir() {
|
|
||||||
FileType::Directory
|
|
||||||
} else if file_type.is_file() {
|
|
||||||
FileType::File
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
files.push(DirectoryEntry::new(name, file_type));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(files)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_dir<P>(path: P) -> Result<()>
|
|
||||||
where
|
|
||||||
P: AsRef<Path>,
|
|
||||||
{
|
|
||||||
fs::create_dir(path).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_dir<P>(path: P) -> Result<()>
|
|
||||||
where
|
|
||||||
P: AsRef<Path>,
|
|
||||||
{
|
|
||||||
fs::remove_dir_all(path).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use tokio::{fs, io::AsyncWriteExt};
|
|
||||||
|
|
||||||
use crate::Result;
|
|
||||||
|
|
||||||
pub async fn write_file<P>(path: P, data: &[u8]) -> Result<()>
|
|
||||||
where
|
|
||||||
P: AsRef<Path>,
|
|
||||||
{
|
|
||||||
let mut file = fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create(true)
|
|
||||||
.open(path)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
file.write_all(data).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_file<P>(path: P) -> Result<()>
|
|
||||||
where
|
|
||||||
P: AsRef<Path>,
|
|
||||||
{
|
|
||||||
fs::remove_file(path).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
mod dir;
|
|
||||||
mod file;
|
|
||||||
pub use dir::*;
|
|
||||||
pub use file::*;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub enum FileType {
|
|
||||||
File,
|
|
||||||
Directory,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct DirectoryEntry {
|
|
||||||
name: String,
|
|
||||||
file_type: FileType,
|
|
||||||
mime_type: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DirectoryEntry {
|
|
||||||
pub fn new(name: String, file_type: FileType) -> Self {
|
|
||||||
let mime_type = match name.split("/").last() {
|
|
||||||
Some(last) if last.contains(".") => {
|
|
||||||
mime_guess::from_path(&name).first_raw().map(str::to_owned)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
|
||||||
name,
|
|
||||||
file_type,
|
|
||||||
mime_type,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
backend/src/lib/inbound/http/handlers/mod.rs
Normal file
1
backend/src/lib/inbound/http/handlers/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod warrens;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
use axum::{Json, extract::State, http::StatusCode};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use thiserror::Error;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::{
|
||||||
|
file_system::models::file::{AbsoluteFilePathError, FilePath, FilePathError},
|
||||||
|
warren::{
|
||||||
|
models::warren::{CreateWarrenDirectoryError, CreateWarrenDirectoryRequest},
|
||||||
|
ports::WarrenService,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
inbound::http::{
|
||||||
|
AppState,
|
||||||
|
responses::{ApiError, ApiSuccess},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreateWarrenDirectoryHttpRequestBody {
|
||||||
|
warren_id: Uuid,
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Error)]
|
||||||
|
pub enum ParseCreateWarrenDirectoryHttpRequestError {
|
||||||
|
#[error(transparent)]
|
||||||
|
FilePath(#[from] FilePathError),
|
||||||
|
#[error(transparent)]
|
||||||
|
AbsoluteFilePath(#[from] AbsoluteFilePathError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ParseCreateWarrenDirectoryHttpRequestError> for ApiError {
|
||||||
|
fn from(value: ParseCreateWarrenDirectoryHttpRequestError) -> Self {
|
||||||
|
match value {
|
||||||
|
ParseCreateWarrenDirectoryHttpRequestError::FilePath(err) => match err {
|
||||||
|
FilePathError::InvalidPath => {
|
||||||
|
ApiError::BadRequest("The file path must be valid".to_string())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ParseCreateWarrenDirectoryHttpRequestError::AbsoluteFilePath(err) => match err {
|
||||||
|
AbsoluteFilePathError::NotAbsolute => {
|
||||||
|
ApiError::BadRequest("The file path must be absolute".to_string())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateWarrenDirectoryHttpRequestBody {
|
||||||
|
fn try_into_domain(
|
||||||
|
self,
|
||||||
|
) -> Result<CreateWarrenDirectoryRequest, ParseCreateWarrenDirectoryHttpRequestError> {
|
||||||
|
let path = FilePath::new(&self.path)?;
|
||||||
|
|
||||||
|
Ok(CreateWarrenDirectoryRequest::new(
|
||||||
|
self.warren_id,
|
||||||
|
path.try_into()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CreateWarrenDirectoryError> for ApiError {
|
||||||
|
fn from(_value: CreateWarrenDirectoryError) -> Self {
|
||||||
|
ApiError::InternalServerError("Internal server error".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_warren_directory<WS>(
|
||||||
|
State(state): State<AppState<WS>>,
|
||||||
|
Json(request): Json<CreateWarrenDirectoryHttpRequestBody>,
|
||||||
|
) -> Result<ApiSuccess<()>, ApiError>
|
||||||
|
where
|
||||||
|
WS: WarrenService,
|
||||||
|
{
|
||||||
|
let domain_request = request.try_into_domain()?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.warren_service
|
||||||
|
.create_warren_directory(domain_request)
|
||||||
|
.await
|
||||||
|
.map(|_| ApiSuccess::new(StatusCode::CREATED, ()))
|
||||||
|
.map_err(ApiError::from)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
use axum::{Json, extract::State, http::StatusCode};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use thiserror::Error;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::{
|
||||||
|
file_system::models::file::{AbsoluteFilePathError, FilePath, FilePathError},
|
||||||
|
warren::{
|
||||||
|
models::warren::{DeleteWarrenDirectoryError, DeleteWarrenDirectoryRequest},
|
||||||
|
ports::WarrenService,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
inbound::http::{
|
||||||
|
AppState,
|
||||||
|
responses::{ApiError, ApiSuccess},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DeleteWarrenDirectoryHttpRequestBody {
|
||||||
|
warren_id: Uuid,
|
||||||
|
path: String,
|
||||||
|
force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Error)]
|
||||||
|
pub enum ParseDeleteWarrenDirectoryHttpRequestError {
|
||||||
|
#[error(transparent)]
|
||||||
|
FilePath(#[from] FilePathError),
|
||||||
|
#[error(transparent)]
|
||||||
|
AbsoluteFilePath(#[from] AbsoluteFilePathError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ParseDeleteWarrenDirectoryHttpRequestError> for ApiError {
|
||||||
|
fn from(value: ParseDeleteWarrenDirectoryHttpRequestError) -> Self {
|
||||||
|
match value {
|
||||||
|
ParseDeleteWarrenDirectoryHttpRequestError::FilePath(err) => match err {
|
||||||
|
FilePathError::InvalidPath => {
|
||||||
|
ApiError::BadRequest("The file path must be valid".to_string())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ParseDeleteWarrenDirectoryHttpRequestError::AbsoluteFilePath(err) => match err {
|
||||||
|
AbsoluteFilePathError::NotAbsolute => {
|
||||||
|
ApiError::BadRequest("The file path must be absolute".to_string())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteWarrenDirectoryHttpRequestBody {
|
||||||
|
fn try_into_domain(
|
||||||
|
self,
|
||||||
|
) -> Result<DeleteWarrenDirectoryRequest, ParseDeleteWarrenDirectoryHttpRequestError> {
|
||||||
|
let path = FilePath::new(&self.path)?;
|
||||||
|
|
||||||
|
Ok(DeleteWarrenDirectoryRequest::new(
|
||||||
|
self.warren_id,
|
||||||
|
path.try_into()?,
|
||||||
|
self.force,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DeleteWarrenDirectoryError> for ApiError {
|
||||||
|
fn from(_value: DeleteWarrenDirectoryError) -> Self {
|
||||||
|
ApiError::InternalServerError("Internal server error".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_warren_directory<WS>(
|
||||||
|
State(state): State<AppState<WS>>,
|
||||||
|
Json(request): Json<DeleteWarrenDirectoryHttpRequestBody>,
|
||||||
|
) -> Result<ApiSuccess<()>, ApiError>
|
||||||
|
where
|
||||||
|
WS: WarrenService,
|
||||||
|
{
|
||||||
|
let domain_request = request.try_into_domain()?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.warren_service
|
||||||
|
.delete_warren_directory(domain_request)
|
||||||
|
.await
|
||||||
|
.map(|_| ApiSuccess::new(StatusCode::CREATED, ()))
|
||||||
|
.map_err(ApiError::from)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
use axum::{Json, extract::State, http::StatusCode};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use thiserror::Error;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::{
|
||||||
|
file_system::models::file::{AbsoluteFilePathError, FilePath, FilePathError},
|
||||||
|
warren::{
|
||||||
|
models::warren::{DeleteWarrenFileError, DeleteWarrenFileRequest},
|
||||||
|
ports::WarrenService,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
inbound::http::{
|
||||||
|
AppState,
|
||||||
|
responses::{ApiError, ApiSuccess},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DeleteWarrenFileHttpRequestBody {
|
||||||
|
warren_id: Uuid,
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Error)]
|
||||||
|
pub enum ParseDeleteWarrenFileHttpRequestError {
|
||||||
|
#[error(transparent)]
|
||||||
|
FilePath(#[from] FilePathError),
|
||||||
|
#[error(transparent)]
|
||||||
|
AbsoluteFilePath(#[from] AbsoluteFilePathError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ParseDeleteWarrenFileHttpRequestError> for ApiError {
|
||||||
|
fn from(value: ParseDeleteWarrenFileHttpRequestError) -> Self {
|
||||||
|
match value {
|
||||||
|
ParseDeleteWarrenFileHttpRequestError::FilePath(err) => match err {
|
||||||
|
FilePathError::InvalidPath => {
|
||||||
|
ApiError::BadRequest("The file path must be valid".to_string())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ParseDeleteWarrenFileHttpRequestError::AbsoluteFilePath(err) => match err {
|
||||||
|
AbsoluteFilePathError::NotAbsolute => {
|
||||||
|
ApiError::BadRequest("The file path must be absolute".to_string())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteWarrenFileHttpRequestBody {
|
||||||
|
fn try_into_domain(
|
||||||
|
self,
|
||||||
|
) -> Result<DeleteWarrenFileRequest, ParseDeleteWarrenFileHttpRequestError> {
|
||||||
|
let path = FilePath::new(&self.path)?;
|
||||||
|
|
||||||
|
Ok(DeleteWarrenFileRequest::new(
|
||||||
|
self.warren_id,
|
||||||
|
path.try_into()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DeleteWarrenFileError> for ApiError {
|
||||||
|
fn from(_value: DeleteWarrenFileError) -> Self {
|
||||||
|
ApiError::InternalServerError("Internal server error".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_warren_file<WS>(
|
||||||
|
State(state): State<AppState<WS>>,
|
||||||
|
Json(request): Json<DeleteWarrenFileHttpRequestBody>,
|
||||||
|
) -> Result<ApiSuccess<()>, ApiError>
|
||||||
|
where
|
||||||
|
WS: WarrenService,
|
||||||
|
{
|
||||||
|
let domain_request = request.try_into_domain()?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.warren_service
|
||||||
|
.delete_warren_file(domain_request)
|
||||||
|
.await
|
||||||
|
.map(|_| ApiSuccess::new(StatusCode::CREATED, ()))
|
||||||
|
.map_err(ApiError::from)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
use axum::{Json, extract::State, http::StatusCode};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::warren::{
|
||||||
|
models::warren::{FetchWarrenError, FetchWarrenRequest, Warren},
|
||||||
|
ports::WarrenService,
|
||||||
|
},
|
||||||
|
inbound::http::{
|
||||||
|
AppState,
|
||||||
|
responses::{ApiError, ApiSuccess},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct FetchWarrenResponseData {
|
||||||
|
id: Uuid,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Into<FetchWarrenResponseData> for &Warren {
|
||||||
|
fn into(self) -> FetchWarrenResponseData {
|
||||||
|
FetchWarrenResponseData {
|
||||||
|
id: *self.id(),
|
||||||
|
name: self.name().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<FetchWarrenError> for ApiError {
|
||||||
|
fn from(error: FetchWarrenError) -> Self {
|
||||||
|
match error {
|
||||||
|
FetchWarrenError::NotFound(uuid) => {
|
||||||
|
Self::NotFound(format!("Warren with id {uuid} does not exist"))
|
||||||
|
}
|
||||||
|
FetchWarrenError::Unknown(_cause) => {
|
||||||
|
Self::InternalServerError("Internal server error".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Error)]
|
||||||
|
enum ParseFetchWarrenHttpRequestError {}
|
||||||
|
|
||||||
|
impl From<ParseFetchWarrenHttpRequestError> for ApiError {
|
||||||
|
fn from(_e: ParseFetchWarrenHttpRequestError) -> Self {
|
||||||
|
ApiError::InternalServerError("Internal server error".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
pub struct FetchWarrenHttpRequestBody {
|
||||||
|
id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FetchWarrenHttpRequestBody {
|
||||||
|
fn try_into_domain(self) -> Result<FetchWarrenRequest, ParseFetchWarrenHttpRequestError> {
|
||||||
|
Ok(FetchWarrenRequest::new(self.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_warren<WS: WarrenService>(
|
||||||
|
State(state): State<AppState<WS>>,
|
||||||
|
Json(request): Json<FetchWarrenHttpRequestBody>,
|
||||||
|
) -> Result<ApiSuccess<FetchWarrenResponseData>, ApiError> {
|
||||||
|
let domain_request = request.try_into_domain()?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.warren_service
|
||||||
|
.fetch_warren(domain_request)
|
||||||
|
.await
|
||||||
|
.map(|ref warren| ApiSuccess::new(StatusCode::OK, warren.into()))
|
||||||
|
.map_err(ApiError::from)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
use axum::{Json, extract::State, http::StatusCode};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::{
|
||||||
|
file_system::models::file::{
|
||||||
|
AbsoluteFilePathError, File, FileMimeType, FilePath, FilePathError, FileType,
|
||||||
|
},
|
||||||
|
warren::{
|
||||||
|
models::warren::{ListWarrenFilesError, ListWarrenFilesRequest},
|
||||||
|
ports::WarrenService,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
inbound::http::{
|
||||||
|
AppState,
|
||||||
|
responses::{ApiError, ApiSuccess},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Error)]
|
||||||
|
pub enum ParseListWarrenHttpRequestError {
|
||||||
|
#[error(transparent)]
|
||||||
|
FilePath(#[from] FilePathError),
|
||||||
|
#[error(transparent)]
|
||||||
|
AbsoluteFilePath(#[from] AbsoluteFilePathError),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ListWarrenFilesHttpRequestBody {
|
||||||
|
warren_id: Uuid,
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListWarrenFilesHttpRequestBody {
|
||||||
|
fn try_into_domain(self) -> Result<ListWarrenFilesRequest, ParseListWarrenHttpRequestError> {
|
||||||
|
let path = FilePath::new(&self.path)?;
|
||||||
|
|
||||||
|
Ok(ListWarrenFilesRequest::new(
|
||||||
|
self.warren_id,
|
||||||
|
path.try_into()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ParseListWarrenHttpRequestError> for ApiError {
|
||||||
|
fn from(value: ParseListWarrenHttpRequestError) -> Self {
|
||||||
|
match value {
|
||||||
|
ParseListWarrenHttpRequestError::FilePath(err) => match err {
|
||||||
|
FilePathError::InvalidPath => {
|
||||||
|
ApiError::BadRequest("The file path must be valid".to_string())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ParseListWarrenHttpRequestError::AbsoluteFilePath(err) => match err {
|
||||||
|
AbsoluteFilePathError::NotAbsolute => {
|
||||||
|
ApiError::BadRequest("The file path must be absolute".to_string())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct WarrenFileElement {
|
||||||
|
name: String,
|
||||||
|
file_type: FileType,
|
||||||
|
mime_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ListWarrenFilesResponseData {
|
||||||
|
files: Vec<WarrenFileElement>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<File> for WarrenFileElement {
|
||||||
|
fn from(value: File) -> Self {
|
||||||
|
Self {
|
||||||
|
name: value.name().to_string(),
|
||||||
|
file_type: value.file_type().to_owned(),
|
||||||
|
mime_type: value.mime_type().map(FileMimeType::to_string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Vec<File>> for ListWarrenFilesResponseData {
|
||||||
|
fn from(value: Vec<File>) -> Self {
|
||||||
|
Self {
|
||||||
|
files: value.into_iter().map(WarrenFileElement::from).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ListWarrenFilesError> for ApiError {
|
||||||
|
fn from(_value: ListWarrenFilesError) -> Self {
|
||||||
|
ApiError::InternalServerError("Internal server error".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_warren_files<WS: WarrenService>(
|
||||||
|
State(state): State<AppState<WS>>,
|
||||||
|
Json(request): Json<ListWarrenFilesHttpRequestBody>,
|
||||||
|
) -> Result<ApiSuccess<ListWarrenFilesResponseData>, ApiError> {
|
||||||
|
let domain_request = request.try_into_domain()?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.warren_service
|
||||||
|
.list_files(domain_request)
|
||||||
|
.await
|
||||||
|
.map(|files| ApiSuccess::new(StatusCode::OK, files.into()))
|
||||||
|
.map_err(ApiError::from)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
use axum::{extract::State, http::StatusCode};
|
||||||
|
use serde::Serialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::warren::{
|
||||||
|
models::warren::{ListWarrensError, ListWarrensRequest, Warren},
|
||||||
|
ports::WarrenService,
|
||||||
|
},
|
||||||
|
inbound::http::{
|
||||||
|
AppState,
|
||||||
|
responses::{ApiError, ApiSuccess},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct WarrenListElement {
|
||||||
|
id: Uuid,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Warren> for WarrenListElement {
|
||||||
|
fn from(value: &Warren) -> Self {
|
||||||
|
Self {
|
||||||
|
id: *value.id(),
|
||||||
|
name: value.name().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct ListWarrensResponseData {
|
||||||
|
warrens: Vec<WarrenListElement>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Vec<Warren>> for ListWarrensResponseData {
|
||||||
|
fn from(value: &Vec<Warren>) -> Self {
|
||||||
|
ListWarrensResponseData {
|
||||||
|
warrens: value.into_iter().map(WarrenListElement::from).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ListWarrensError> for ApiError {
|
||||||
|
fn from(error: ListWarrensError) -> Self {
|
||||||
|
match error {
|
||||||
|
ListWarrensError::Unknown(_cause) => {
|
||||||
|
Self::InternalServerError("Internal server error".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_warrens<WS: WarrenService>(
|
||||||
|
State(state): State<AppState<WS>>,
|
||||||
|
) -> Result<ApiSuccess<ListWarrensResponseData>, ApiError> {
|
||||||
|
let domain_request = ListWarrensRequest::new();
|
||||||
|
|
||||||
|
state
|
||||||
|
.warren_service
|
||||||
|
.list_warrens(domain_request)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::from)
|
||||||
|
.map(|ref warrens| ApiSuccess::new(StatusCode::OK, warrens.into()))
|
||||||
|
}
|
||||||
40
backend/src/lib/inbound/http/handlers/warrens/mod.rs
Normal file
40
backend/src/lib/inbound/http/handlers/warrens/mod.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
mod create_warren_directory;
|
||||||
|
mod delete_warren_directory;
|
||||||
|
mod delete_warren_file;
|
||||||
|
mod fetch_warren;
|
||||||
|
mod list_warren_files;
|
||||||
|
mod list_warrens;
|
||||||
|
mod upload_warren_files;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
|
routing::{delete, get, post},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{domain::warren::ports::WarrenService, inbound::http::AppState};
|
||||||
|
|
||||||
|
use fetch_warren::fetch_warren;
|
||||||
|
use list_warren_files::list_warren_files;
|
||||||
|
use list_warrens::list_warrens;
|
||||||
|
|
||||||
|
use create_warren_directory::create_warren_directory;
|
||||||
|
use delete_warren_directory::delete_warren_directory;
|
||||||
|
|
||||||
|
use delete_warren_file::delete_warren_file;
|
||||||
|
use upload_warren_files::upload_warren_files;
|
||||||
|
|
||||||
|
pub fn routes<WS: WarrenService>() -> Router<AppState<WS>> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(list_warrens))
|
||||||
|
.route("/", post(fetch_warren))
|
||||||
|
.route("/files", post(list_warren_files))
|
||||||
|
.route("/files/directory", post(create_warren_directory))
|
||||||
|
.route("/files/directory", delete(delete_warren_directory))
|
||||||
|
.route(
|
||||||
|
"/files/upload",
|
||||||
|
// 1073741824 bytes = 1GB
|
||||||
|
post(upload_warren_files).route_layer(DefaultBodyLimit::max(1073741824)),
|
||||||
|
)
|
||||||
|
.route("/files/file", delete(delete_warren_file))
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
use axum::{body::Bytes, extract::State, http::StatusCode};
|
||||||
|
use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
|
||||||
|
use thiserror::Error;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::{
|
||||||
|
file_system::models::file::{
|
||||||
|
AbsoluteFilePathError, FileName, FileNameError, FilePath, FilePathError,
|
||||||
|
},
|
||||||
|
warren::{
|
||||||
|
models::warren::{
|
||||||
|
UploadFile, UploadFileList, UploadFileListError, UploadWarrenFilesError,
|
||||||
|
UploadWarrenFilesRequest,
|
||||||
|
},
|
||||||
|
ports::WarrenService,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
inbound::http::{
|
||||||
|
AppState,
|
||||||
|
responses::{ApiError, ApiSuccess},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, TryFromMultipart)]
|
||||||
|
#[try_from_multipart(rename_all = "camelCase")]
|
||||||
|
pub struct UploadWarrenFilesHttpRequestBody {
|
||||||
|
warren_id: Uuid,
|
||||||
|
path: String,
|
||||||
|
files: Vec<FieldData<Bytes>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Error)]
|
||||||
|
enum ParseUploadWarrenFilesHttpRequestError {
|
||||||
|
#[error(transparent)]
|
||||||
|
FileName(#[from] FileNameError),
|
||||||
|
#[error(transparent)]
|
||||||
|
FilePath(#[from] FilePathError),
|
||||||
|
#[error(transparent)]
|
||||||
|
FileList(#[from] UploadFileListError),
|
||||||
|
#[error(transparent)]
|
||||||
|
AbsoluteFilePath(#[from] AbsoluteFilePathError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UploadWarrenFilesHttpRequestBody {
|
||||||
|
fn try_into_domain(
|
||||||
|
self,
|
||||||
|
) -> Result<UploadWarrenFilesRequest, ParseUploadWarrenFilesHttpRequestError> {
|
||||||
|
let path = FilePath::new(&self.path)?;
|
||||||
|
|
||||||
|
let mut files = Vec::with_capacity(self.files.len());
|
||||||
|
|
||||||
|
for file in self.files {
|
||||||
|
let raw_file_name = file.metadata.file_name.ok_or(FileNameError::Empty)?;
|
||||||
|
let file_name = FileName::new(&raw_file_name)?;
|
||||||
|
let data = file.contents.to_vec().into_boxed_slice();
|
||||||
|
files.push(UploadFile::new(file_name, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
let files = UploadFileList::new(files)?;
|
||||||
|
|
||||||
|
Ok(UploadWarrenFilesRequest::new(
|
||||||
|
self.warren_id,
|
||||||
|
path.try_into()?,
|
||||||
|
files,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ParseUploadWarrenFilesHttpRequestError> for ApiError {
|
||||||
|
fn from(value: ParseUploadWarrenFilesHttpRequestError) -> Self {
|
||||||
|
match value {
|
||||||
|
ParseUploadWarrenFilesHttpRequestError::FilePath(err) => match err {
|
||||||
|
FilePathError::InvalidPath => {
|
||||||
|
ApiError::BadRequest("The file path must be valid".to_string())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ParseUploadWarrenFilesHttpRequestError::AbsoluteFilePath(err) => match err {
|
||||||
|
AbsoluteFilePathError::NotAbsolute => {
|
||||||
|
ApiError::BadRequest("The file path must be absolute".to_string())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ParseUploadWarrenFilesHttpRequestError::FileList(err) => match err {
|
||||||
|
UploadFileListError::Empty => {
|
||||||
|
ApiError::BadRequest("There has to be at least 1 file present".to_string())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ParseUploadWarrenFilesHttpRequestError::FileName(err) => match err {
|
||||||
|
FileNameError::Slash => {
|
||||||
|
ApiError::BadRequest("A file's name contained an invalid character".to_string())
|
||||||
|
}
|
||||||
|
FileNameError::Empty => {
|
||||||
|
ApiError::BadRequest("File names must not be empty".to_string())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<UploadWarrenFilesError> for ApiError {
|
||||||
|
fn from(_: UploadWarrenFilesError) -> Self {
|
||||||
|
ApiError::InternalServerError("Internal server error".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upload_warren_files<WS>(
|
||||||
|
State(state): State<AppState<WS>>,
|
||||||
|
TypedMultipart(multipart): TypedMultipart<UploadWarrenFilesHttpRequestBody>,
|
||||||
|
) -> Result<ApiSuccess<()>, ApiError>
|
||||||
|
where
|
||||||
|
WS: WarrenService,
|
||||||
|
{
|
||||||
|
let domain_request = multipart.try_into_domain()?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.warren_service
|
||||||
|
.upload_warren_files(domain_request)
|
||||||
|
.await
|
||||||
|
.map(|_| ApiSuccess::new(StatusCode::CREATED, ()))
|
||||||
|
.map_err(ApiError::from)
|
||||||
|
}
|
||||||
107
backend/src/lib/inbound/http/mod.rs
Normal file
107
backend/src/lib/inbound/http/mod.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
pub mod handlers;
|
||||||
|
pub mod responses;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use axum::{Router, http::HeaderValue};
|
||||||
|
use handlers::warrens;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tower_http::{cors::CorsLayer, services::ServeDir};
|
||||||
|
|
||||||
|
use crate::domain::warren::ports::WarrenService;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct HttpServerConfig<'a> {
|
||||||
|
pub address: &'a str,
|
||||||
|
pub port: &'a u16,
|
||||||
|
|
||||||
|
pub cors_allow_origin: &'a str,
|
||||||
|
|
||||||
|
pub static_frontend_dir: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> HttpServerConfig<'a> {
|
||||||
|
pub fn new(
|
||||||
|
address: &'a str,
|
||||||
|
port: &'a u16,
|
||||||
|
cors_allow_origin: &'a str,
|
||||||
|
static_frontend_dir: Option<&'a str>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
address,
|
||||||
|
port,
|
||||||
|
cors_allow_origin,
|
||||||
|
|
||||||
|
static_frontend_dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AppState<WS: WarrenService> {
|
||||||
|
warren_service: Arc<WS>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HttpServer {
|
||||||
|
router: Router,
|
||||||
|
listener: TcpListener,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpServer {
|
||||||
|
pub async fn new<WS: WarrenService>(
|
||||||
|
warren_service: WS,
|
||||||
|
config: HttpServerConfig<'_>,
|
||||||
|
) -> anyhow::Result<Self> {
|
||||||
|
let cors_layer = cors_layer(&config)?;
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
warren_service: Arc::new(warren_service),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut router = Router::new()
|
||||||
|
.nest("/api", api_routes())
|
||||||
|
.layer(cors_layer)
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
if let Some(frontend_dir) = config.static_frontend_dir {
|
||||||
|
let frontend_service = ServeDir::new(frontend_dir);
|
||||||
|
|
||||||
|
log::debug!("Registering static frontend");
|
||||||
|
router = router.fallback_service(frontend_service);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bind_addr = format!("{}:{}", config.address, config.port);
|
||||||
|
let listener = TcpListener::bind(&bind_addr)
|
||||||
|
.await
|
||||||
|
.context(format!("Failed to listen on {bind_addr}"))?;
|
||||||
|
|
||||||
|
Ok(Self { router, listener })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(self) -> anyhow::Result<()> {
|
||||||
|
log::info!("Listening on {}", self.listener.local_addr()?);
|
||||||
|
|
||||||
|
axum::serve(self.listener, self.router)
|
||||||
|
.await
|
||||||
|
.context("HTTP server error")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cors_layer(config: &HttpServerConfig<'_>) -> anyhow::Result<CorsLayer> {
|
||||||
|
let origin = HeaderValue::from_str(config.cors_allow_origin)
|
||||||
|
.context("Failed to convert cors_allow_origin to a header")?;
|
||||||
|
|
||||||
|
let layer = CorsLayer::default()
|
||||||
|
.allow_origin(origin)
|
||||||
|
.allow_methods(tower_http::cors::Any)
|
||||||
|
.allow_headers(tower_http::cors::Any);
|
||||||
|
|
||||||
|
Ok(layer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_routes<WS: WarrenService>() -> Router<AppState<WS>> {
|
||||||
|
Router::new().nest("/warrens", warrens::routes())
|
||||||
|
}
|
||||||
76
backend/src/lib/inbound/http/responses.rs
Normal file
76
backend/src/lib/inbound/http/responses.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// Generic response structure shared by all API responses.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct ResponseBody<T: Serialize + PartialEq> {
|
||||||
|
#[serde(rename = "status")]
|
||||||
|
status_code: u16,
|
||||||
|
data: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Serialize + PartialEq> ResponseBody<T> {
|
||||||
|
pub fn new(status: StatusCode, data: T) -> Self {
|
||||||
|
Self {
|
||||||
|
status_code: status.as_u16(),
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ApiSuccess<T: Serialize + PartialEq>(StatusCode, Json<ResponseBody<T>>);
|
||||||
|
|
||||||
|
impl<T> PartialEq for ApiSuccess<T>
|
||||||
|
where
|
||||||
|
T: Serialize + PartialEq,
|
||||||
|
{
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.0 == other.0 && self.1.0 == other.1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Serialize + PartialEq> ApiSuccess<T> {
|
||||||
|
pub fn new(status: StatusCode, data: T) -> Self {
|
||||||
|
Self(status, Json(ResponseBody::new(status, data)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Serialize + PartialEq> IntoResponse for ApiSuccess<T> {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
(self.0, self.1).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ApiError {
|
||||||
|
BadRequest(String),
|
||||||
|
NotFound(String),
|
||||||
|
InternalServerError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<anyhow::Error> for ApiError {
|
||||||
|
fn from(e: anyhow::Error) -> Self {
|
||||||
|
Self::InternalServerError(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
use ApiError::*;
|
||||||
|
|
||||||
|
match self {
|
||||||
|
BadRequest(_) => (StatusCode::BAD_REQUEST, "Bad request".to_string()).into_response(),
|
||||||
|
NotFound(_) => (StatusCode::NOT_FOUND, "Not found".to_string()).into_response(),
|
||||||
|
InternalServerError(_) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Internal server error".to_string(),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
backend/src/lib/inbound/mod.rs
Normal file
1
backend/src/lib/inbound/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod http;
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
use error::AppError;
|
pub mod config;
|
||||||
|
pub mod domain;
|
||||||
pub mod api;
|
pub mod inbound;
|
||||||
pub mod db;
|
pub mod outbound;
|
||||||
pub mod error;
|
|
||||||
pub mod fs;
|
|
||||||
pub mod server;
|
|
||||||
pub mod warrens;
|
pub mod warrens;
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, AppError>;
|
|
||||||
|
|||||||
192
backend/src/lib/outbound/file_system.rs
Normal file
192
backend/src/lib/outbound/file_system.rs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
use anyhow::{Context, anyhow};
|
||||||
|
use tokio::{fs, io::AsyncWriteExt as _};
|
||||||
|
|
||||||
|
use crate::domain::file_system::{
|
||||||
|
models::file::{
|
||||||
|
AbsoluteFilePath, CreateDirectoryError, CreateDirectoryRequest, CreateFileError,
|
||||||
|
CreateFileRequest, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError,
|
||||||
|
DeleteFileRequest, File, FileMimeType, FileName, FilePath, FileType, ListFilesError,
|
||||||
|
ListFilesRequest,
|
||||||
|
},
|
||||||
|
ports::FileSystemRepository,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FileSystemConfig {
|
||||||
|
base_directory: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileSystemConfig {
|
||||||
|
pub fn new(base_directory: String) -> Self {
|
||||||
|
Self { base_directory }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FileSystem {
|
||||||
|
base_directory: FilePath,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileSystem {
|
||||||
|
pub fn new(config: FileSystemConfig) -> anyhow::Result<Self> {
|
||||||
|
let file_system = Self {
|
||||||
|
base_directory: FilePath::new(&config.base_directory)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(file_system)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combines `self.base_directory` with the specified path
|
||||||
|
///
|
||||||
|
/// * `path`: The absolute path (absolute in relation to the base directory)
|
||||||
|
fn get_target_path(&self, path: &AbsoluteFilePath) -> FilePath {
|
||||||
|
self.base_directory.join(&path.as_relative())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_all_files(&self, absolute_path: &AbsoluteFilePath) -> anyhow::Result<Vec<File>> {
|
||||||
|
let directory_path = self.get_target_path(absolute_path);
|
||||||
|
|
||||||
|
let mut dir = fs::read_dir(&directory_path).await?;
|
||||||
|
|
||||||
|
let mut files = Vec::new();
|
||||||
|
|
||||||
|
while let Ok(Some(entry)) = dir.next_entry().await {
|
||||||
|
let name = entry
|
||||||
|
.file_name()
|
||||||
|
.into_string()
|
||||||
|
.ok()
|
||||||
|
.context("Failed to get file name")?;
|
||||||
|
let file_type = {
|
||||||
|
let file_type = entry.file_type().await?;
|
||||||
|
|
||||||
|
if file_type.is_dir() {
|
||||||
|
FileType::Directory
|
||||||
|
} else if file_type.is_file() {
|
||||||
|
FileType::File
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mime_type = match file_type {
|
||||||
|
FileType::File => FileMimeType::from_name(&name),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
files.push(File::new(FileName::new(&name)?, file_type, mime_type));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Actually created a directory in the underlying file system
|
||||||
|
///
|
||||||
|
/// * `path`: The directory's absolute path (absolute not in relation to the root file system but `self.base_directory`)
|
||||||
|
async fn create_dir(&self, path: &AbsoluteFilePath) -> anyhow::Result<FilePath> {
|
||||||
|
let file_path = self.get_target_path(path);
|
||||||
|
|
||||||
|
fs::create_dir(&file_path).await?;
|
||||||
|
|
||||||
|
Ok(file_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Actually removes a directory from the underlying file system
|
||||||
|
///
|
||||||
|
/// * `path`: The directory's absolute path (absolute not in relation to the root file system but `self.base_directory`)
|
||||||
|
/// * `force`: Whether to delete directories that are not empty
|
||||||
|
async fn remove_dir(&self, path: &AbsoluteFilePath, force: bool) -> anyhow::Result<FilePath> {
|
||||||
|
let file_path = self.get_target_path(path);
|
||||||
|
|
||||||
|
if force {
|
||||||
|
fs::remove_dir_all(&file_path).await?;
|
||||||
|
} else {
|
||||||
|
fs::remove_dir(&file_path).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(file_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_file(&self, path: &AbsoluteFilePath, data: &[u8]) -> anyhow::Result<FilePath> {
|
||||||
|
let path = self.get_target_path(path);
|
||||||
|
|
||||||
|
let mut file = fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.open(&path)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
file.write_all(data).await?;
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Actually removes a file from the underlying file system
|
||||||
|
///
|
||||||
|
/// * `path`: The file's absolute path (absolute not in relation to the root file system but `self.base_directory`)
|
||||||
|
async fn remove_file(&self, path: &AbsoluteFilePath) -> anyhow::Result<FilePath> {
|
||||||
|
let path = self.get_target_path(path);
|
||||||
|
|
||||||
|
fs::remove_file(&path).await?;
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileSystemRepository for FileSystem {
|
||||||
|
async fn list_files(&self, request: ListFilesRequest) -> Result<Vec<File>, ListFilesError> {
|
||||||
|
let files = self.get_all_files(request.path()).await.map_err(|err| {
|
||||||
|
anyhow!(err).context(format!(
|
||||||
|
"Failed to get the files at path: {}",
|
||||||
|
request.path()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_directory(
|
||||||
|
&self,
|
||||||
|
request: CreateDirectoryRequest,
|
||||||
|
) -> Result<FilePath, CreateDirectoryError> {
|
||||||
|
let created_path = self.create_dir(request.path()).await.context(format!(
|
||||||
|
"Failed to create directory at path {}",
|
||||||
|
request.path()
|
||||||
|
))?;
|
||||||
|
|
||||||
|
Ok(created_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_directory(
|
||||||
|
&self,
|
||||||
|
request: DeleteDirectoryRequest,
|
||||||
|
) -> Result<FilePath, DeleteDirectoryError> {
|
||||||
|
let deleted_path = self
|
||||||
|
.remove_dir(request.path(), false)
|
||||||
|
.await
|
||||||
|
.context(format!("Failed to delete directory at {}", request.path()))?;
|
||||||
|
|
||||||
|
Ok(deleted_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_file(&self, request: CreateFileRequest) -> Result<FilePath, CreateFileError> {
|
||||||
|
let file_path = self
|
||||||
|
.write_file(request.path(), request.data())
|
||||||
|
.await
|
||||||
|
.context(format!(
|
||||||
|
"Failed to write {} byte(s) to path {}",
|
||||||
|
request.data().len(),
|
||||||
|
request.path()
|
||||||
|
))?;
|
||||||
|
|
||||||
|
Ok(file_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_file(&self, request: DeleteFileRequest) -> Result<FilePath, DeleteFileError> {
|
||||||
|
let deleted_path = self
|
||||||
|
.remove_file(request.path())
|
||||||
|
.await
|
||||||
|
.context(format!("Failed to delete file at {}", request.path()))?;
|
||||||
|
|
||||||
|
Ok(deleted_path)
|
||||||
|
}
|
||||||
|
}
|
||||||
110
backend/src/lib/outbound/metrics_debug_logger.rs
Normal file
110
backend/src/lib/outbound/metrics_debug_logger.rs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
use crate::domain::{file_system::ports::FileSystemMetrics, warren::ports::WarrenMetrics};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct MetricsDebugLogger;
|
||||||
|
|
||||||
|
impl MetricsDebugLogger {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WarrenMetrics for MetricsDebugLogger {
|
||||||
|
async fn record_warren_list_success(&self) {
|
||||||
|
log::debug!("[Metrics] Warren list succeeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_warren_list_failure(&self) {
|
||||||
|
log::debug!("[Metrics] Warren list failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_warren_fetch_success(&self) {
|
||||||
|
log::debug!("[Metrics] Warren fetch succeeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_warren_fetch_failure(&self) {
|
||||||
|
log::debug!("[Metrics] Warren fetch failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_list_warren_files_success(&self) {
|
||||||
|
log::debug!("[Metrics] Warren list files succeeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_list_warren_files_failure(&self) {
|
||||||
|
log::debug!("[Metrics] Warren list files failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_warren_directory_creation_success(&self) {
|
||||||
|
log::debug!("[Metrics] Warren directory creation succeeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_warren_directory_creation_failure(&self) {
|
||||||
|
log::debug!("[Metrics] Warren directory creation failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_warren_directory_deletion_success(&self) {
|
||||||
|
log::debug!("[Metrics] Warren directory deletion succeeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_warren_directory_deletion_failure(&self) {
|
||||||
|
log::debug!("[Metrics] Warren directory deletion failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_warren_file_upload_success(&self) {
|
||||||
|
log::debug!("[Metrics] Warren file upload succeeded");
|
||||||
|
}
|
||||||
|
async fn record_warren_file_upload_failure(&self) {
|
||||||
|
log::debug!("[Metrics] Warren file upload failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_warren_files_upload_success(&self) {
|
||||||
|
log::debug!("[Metrics] Warren files upload succeded");
|
||||||
|
}
|
||||||
|
async fn record_warren_files_upload_failure(&self) {
|
||||||
|
log::debug!("[Metrics] Warren files upload failed at least partially");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_warren_file_deletion_success(&self) {
|
||||||
|
log::debug!("[Metrics] Warren file deletion succeeded");
|
||||||
|
}
|
||||||
|
async fn record_warren_file_deletion_failure(&self) {
|
||||||
|
log::debug!("[Metrics] Warren file deletion failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileSystemMetrics for MetricsDebugLogger {
|
||||||
|
async fn record_list_files_success(&self) {
|
||||||
|
log::debug!("[Metrics] File list succeeded");
|
||||||
|
}
|
||||||
|
async fn record_list_files_failure(&self) {
|
||||||
|
log::debug!("[Metrics] File list failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_directory_creation_success(&self) {
|
||||||
|
log::debug!("[Metrics] Directory creation succeeded");
|
||||||
|
}
|
||||||
|
async fn record_directory_creation_failure(&self) {
|
||||||
|
log::debug!("[Metrics] Directory creation failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_directory_deletion_success(&self) {
|
||||||
|
log::debug!("[Metrics] Directory deletion succeeded");
|
||||||
|
}
|
||||||
|
async fn record_directory_deletion_failure(&self) {
|
||||||
|
log::debug!("[Metrics] Directory deletion failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_file_creation_success(&self) {
|
||||||
|
log::debug!("[Metrics] File creation succeeded");
|
||||||
|
}
|
||||||
|
async fn record_file_creation_failure(&self) {
|
||||||
|
log::debug!("[Metrics] File creation failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_file_deletion_success(&self) {
|
||||||
|
log::debug!("[Metrics] File deletion succeeded");
|
||||||
|
}
|
||||||
|
async fn record_file_deletion_failure(&self) {
|
||||||
|
log::debug!("[Metrics] File deletion failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
4
backend/src/lib/outbound/mod.rs
Normal file
4
backend/src/lib/outbound/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod file_system;
|
||||||
|
pub mod metrics_debug_logger;
|
||||||
|
pub mod notifier_debug_logger;
|
||||||
|
pub mod postgres;
|
||||||
96
backend/src/lib/outbound/notifier_debug_logger.rs
Normal file
96
backend/src/lib/outbound/notifier_debug_logger.rs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
use crate::domain::{
|
||||||
|
file_system::{
|
||||||
|
models::file::{File, FilePath},
|
||||||
|
ports::FileSystemNotifier,
|
||||||
|
},
|
||||||
|
warren::{models::warren::Warren, ports::WarrenNotifier},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct NotifierDebugLogger;
|
||||||
|
|
||||||
|
impl NotifierDebugLogger {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WarrenNotifier for NotifierDebugLogger {
|
||||||
|
async fn warrens_listed(&self, warrens: &Vec<Warren>) {
|
||||||
|
log::debug!("[Notifier] Listed {} warren(s)", warrens.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn warren_fetched(&self, warren: &Warren) {
|
||||||
|
log::debug!("[Notifier] Fetched warren {}", warren.name());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn warren_files_listed(&self, warren: &Warren, files: &Vec<File>) {
|
||||||
|
log::debug!(
|
||||||
|
"[Notifier] Listed {} file(s) in warren {}",
|
||||||
|
files.len(),
|
||||||
|
warren.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn warren_directory_created(&self, warren: &Warren, path: &FilePath) {
|
||||||
|
log::debug!(
|
||||||
|
"[Notifier] Created directory {} in warren {}",
|
||||||
|
path,
|
||||||
|
warren.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn warren_directory_deleted(&self, warren: &Warren, path: &FilePath) {
|
||||||
|
log::debug!(
|
||||||
|
"[Notifier] Deleted directory {} in warren {}",
|
||||||
|
path,
|
||||||
|
warren.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn warren_file_uploaded(&self, warren: &Warren, path: &FilePath) {
|
||||||
|
log::debug!(
|
||||||
|
"[Notifier] Uploaded file {} to warren {}",
|
||||||
|
path,
|
||||||
|
warren.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn warren_files_uploaded(&self, warren: &Warren, paths: &[FilePath]) {
|
||||||
|
log::debug!(
|
||||||
|
"[Notifier] Uploaded {} file(s) to warren {}",
|
||||||
|
paths.len(),
|
||||||
|
warren.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn warren_file_deleted(&self, warren: &Warren, path: &FilePath) {
|
||||||
|
log::debug!(
|
||||||
|
"[Notifier] Deleted file {} from warren {}",
|
||||||
|
path,
|
||||||
|
warren.name(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileSystemNotifier for NotifierDebugLogger {
|
||||||
|
async fn files_listed(&self, files: &Vec<File>) {
|
||||||
|
log::debug!("[Notifier] Listed {} file(s)", files.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn directory_created(&self, path: &FilePath) {
|
||||||
|
log::debug!("[Notifier] Created directory {}", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn directory_deleted(&self, path: &FilePath) {
|
||||||
|
log::debug!("[Notifier] Deleted directory {}", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn file_created(&self, path: &FilePath) {
|
||||||
|
log::debug!("[Notifier] Created file {}", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn file_deleted(&self, path: &FilePath) {
|
||||||
|
log::debug!("[Notifier] Deleted file {}", path);
|
||||||
|
}
|
||||||
|
}
|
||||||
148
backend/src/lib/outbound/postgres.rs
Normal file
148
backend/src/lib/outbound/postgres.rs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use anyhow::{Context, anyhow};
|
||||||
|
use sqlx::{
|
||||||
|
ConnectOptions as _, Connection as _, PgConnection, PgPool,
|
||||||
|
postgres::{PgConnectOptions, PgPoolOptions},
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::domain::warren::{
|
||||||
|
models::warren::{
|
||||||
|
FetchWarrenError, FetchWarrenRequest, ListWarrensError, ListWarrensRequest, Warren,
|
||||||
|
},
|
||||||
|
ports::WarrenRepository,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PostgresConfig {
|
||||||
|
database_url: String,
|
||||||
|
database_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresConfig {
|
||||||
|
pub fn new(database_url: String, database_name: String) -> Self {
|
||||||
|
Self {
|
||||||
|
database_url,
|
||||||
|
database_name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Postgres {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Postgres {
|
||||||
|
pub async fn new(config: PostgresConfig) -> anyhow::Result<Self> {
|
||||||
|
let opts = PgConnectOptions::from_str(&config.database_url)?.disable_statement_logging();
|
||||||
|
|
||||||
|
let mut connection = PgConnection::connect_with(&opts)
|
||||||
|
.await
|
||||||
|
.context("Failed to connect to the PostgreSQL database")?;
|
||||||
|
|
||||||
|
// If this fails it's probably because the database already exists, which is exactly what
|
||||||
|
// we want
|
||||||
|
let _ = sqlx::query(&format!("CREATE DATABASE {}", config.database_name))
|
||||||
|
.execute(&mut connection)
|
||||||
|
.await;
|
||||||
|
connection.close().await?;
|
||||||
|
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.connect_with(opts.database(&config.database_name))
|
||||||
|
.await?;
|
||||||
|
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||||
|
|
||||||
|
Ok(Self { pool })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_warren(
|
||||||
|
&self,
|
||||||
|
connection: &mut PgConnection,
|
||||||
|
id: &Uuid,
|
||||||
|
) -> Result<Warren, sqlx::Error> {
|
||||||
|
let warren: Warren = sqlx::query_as(
|
||||||
|
"
|
||||||
|
SELECT
|
||||||
|
*
|
||||||
|
FROM
|
||||||
|
warrens
|
||||||
|
WHERE
|
||||||
|
id = $1
|
||||||
|
",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(connection)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(warren)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_warrens(
|
||||||
|
&self,
|
||||||
|
connection: &mut PgConnection,
|
||||||
|
) -> Result<Vec<Warren>, sqlx::Error> {
|
||||||
|
let warrens: Vec<Warren> = sqlx::query_as::<sqlx::Postgres, Warren>(
|
||||||
|
"
|
||||||
|
SELECT
|
||||||
|
*
|
||||||
|
FROM
|
||||||
|
warrens
|
||||||
|
LIMIT
|
||||||
|
50
|
||||||
|
",
|
||||||
|
)
|
||||||
|
.fetch_all(&mut *connection)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(warrens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WarrenRepository for Postgres {
|
||||||
|
async fn list_warrens(
|
||||||
|
&self,
|
||||||
|
_request: ListWarrensRequest,
|
||||||
|
) -> Result<Vec<Warren>, ListWarrensError> {
|
||||||
|
let mut connection = self
|
||||||
|
.pool
|
||||||
|
.acquire()
|
||||||
|
.await
|
||||||
|
.context("Failed to get a PostgreSQL connection")?;
|
||||||
|
|
||||||
|
let warrens = self
|
||||||
|
.list_warrens(&mut connection)
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow!(err).context("Failed to fetch warren with id"))?;
|
||||||
|
|
||||||
|
Ok(warrens)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_warren(&self, request: FetchWarrenRequest) -> Result<Warren, FetchWarrenError> {
|
||||||
|
let mut connection = self
|
||||||
|
.pool
|
||||||
|
.acquire()
|
||||||
|
.await
|
||||||
|
.context("Failed to get a PostgreSQL connection")?;
|
||||||
|
|
||||||
|
let warren = self
|
||||||
|
.get_warren(&mut connection, request.id())
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
if is_not_found_error(&err) {
|
||||||
|
return FetchWarrenError::NotFound(request.id().clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
anyhow!(err)
|
||||||
|
.context(format!("Failed to fetch warren with id {:?}", request.id()))
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(warren)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_not_found_error(err: &sqlx::Error) -> bool {
|
||||||
|
matches!(err, sqlx::Error::RowNotFound)
|
||||||
|
}
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
use std::env;
|
|
||||||
|
|
||||||
use axum::http::HeaderValue;
|
|
||||||
use sqlx::{Pool, Postgres};
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
use tower_http::{
|
|
||||||
cors::{self, CorsLayer},
|
|
||||||
services::ServeDir,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
Result,
|
|
||||||
api::{self, AppState},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub type Router = axum::Router<AppState>;
|
|
||||||
|
|
||||||
pub async fn start(pool: Pool<Postgres>) -> Result<()> {
|
|
||||||
let cors_origin = env::var("CORS_ALLOW_ORIGIN").unwrap_or("*".to_string());
|
|
||||||
let serve_dir = std::env::var("SERVE_DIRECTORY")?;
|
|
||||||
|
|
||||||
let mut app = Router::new()
|
|
||||||
.nest("/api", api::router())
|
|
||||||
.layer(
|
|
||||||
CorsLayer::new()
|
|
||||||
.allow_origin(cors::AllowOrigin::exact(HeaderValue::from_str(
|
|
||||||
&cors_origin,
|
|
||||||
)?))
|
|
||||||
.allow_methods(cors::Any),
|
|
||||||
)
|
|
||||||
.with_state(AppState::new(pool, serve_dir));
|
|
||||||
|
|
||||||
if !cfg!(debug_assertions) {
|
|
||||||
let frontend_path = env::var("STATIC_FRONTEND_DIR").unwrap_or("./frontend".to_string());
|
|
||||||
let frontend_service = ServeDir::new(frontend_path);
|
|
||||||
|
|
||||||
log::debug!("Registering static frontend");
|
|
||||||
app = app.fallback_service(frontend_service);
|
|
||||||
}
|
|
||||||
|
|
||||||
let addr = env::var("BIND_ADDRESS").unwrap_or("127.0.0.1:8080".to_string());
|
|
||||||
|
|
||||||
let listener = TcpListener::bind(&addr).await?;
|
|
||||||
|
|
||||||
log::info!("Listening on {}", addr);
|
|
||||||
|
|
||||||
axum::serve(listener, app).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
pub mod db;
|
/* pub mod db;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
@@ -92,4 +92,4 @@ fn build_path(serve_path: &str, warren_path: &str, rest_path: Option<&str>) -> P
|
|||||||
}
|
}
|
||||||
|
|
||||||
final_path
|
final_path
|
||||||
}
|
} */
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
} from '@/components/ui/context-menu';
|
} from '@/components/ui/context-menu';
|
||||||
import { deleteWarrenEntry } from '~/lib/api/warrens';
|
import { deleteWarrenDirectory, deleteWarrenFile } from '~/lib/api/warrens';
|
||||||
import type { DirectoryEntry } from '~/types';
|
import type { DirectoryEntry } from '~/types';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -21,7 +21,11 @@ const deleting = ref(false);
|
|||||||
async function submitDelete() {
|
async function submitDelete() {
|
||||||
deleting.value = true;
|
deleting.value = true;
|
||||||
|
|
||||||
await deleteWarrenEntry(warrenRoute.value, entry.name, entry.fileType);
|
if (entry.fileType === 'directory') {
|
||||||
|
await deleteWarrenDirectory(warrenRoute.value, entry.name);
|
||||||
|
} else {
|
||||||
|
await deleteWarrenFile(warrenRoute.value, entry.name);
|
||||||
|
}
|
||||||
|
|
||||||
deleting.value = false;
|
deleting.value = false;
|
||||||
}
|
}
|
||||||
@@ -32,7 +36,13 @@ async function submitDelete() {
|
|||||||
<ContextMenuTrigger>
|
<ContextMenuTrigger>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="joinPaths(route.path, entry.name)"
|
:to="joinPaths(route.path, entry.name)"
|
||||||
:class="['select-none', { 'pointer-events-none': disabled }]"
|
:class="[
|
||||||
|
'select-none',
|
||||||
|
{
|
||||||
|
'pointer-events-none':
|
||||||
|
disabled || entry.fileType === 'file',
|
||||||
|
},
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
class="w-44 h-12"
|
class="w-44 h-12"
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import type { DirectoryEntry, FileType } from '~/types';
|
import type { DirectoryEntry, FileType } from '~/types';
|
||||||
|
import type { ApiResponse } from '~/types/api';
|
||||||
import type { Warren } from '~/types/warrens';
|
import type { Warren } from '~/types/warrens';
|
||||||
|
|
||||||
export async function getWarrens(): Promise<Record<string, Warren>> {
|
export async function getWarrens(): Promise<Record<string, Warren>> {
|
||||||
const { data: arr, error } = await useFetch<Warren[]>(
|
const { data, error } = await useFetch<ApiResponse<{ warrens: Warren[] }>>(
|
||||||
getApiUrl('warrens'),
|
getApiUrl('warrens'),
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (arr.value == null) {
|
if (data.value == null) {
|
||||||
throw error.value?.name;
|
throw error.value?.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
const warrens: Record<string, Warren> = {};
|
const warrens: Record<string, Warren> = {};
|
||||||
|
|
||||||
for (const warren of arr.value) {
|
for (const warren of data.value.data.warrens) {
|
||||||
warrens[warren.id] = warren;
|
warrens[warren.id] = warren;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,23 +31,31 @@ export async function getWarrenDirectory(
|
|||||||
let [warrenId, rest] = splitOnce(path, '/');
|
let [warrenId, rest] = splitOnce(path, '/');
|
||||||
|
|
||||||
if (rest == null) {
|
if (rest == null) {
|
||||||
rest = '';
|
rest = '/';
|
||||||
} else {
|
} else {
|
||||||
rest = '/' + rest;
|
rest = '/' + decodeURI(rest);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: entries, error } = await useFetch<DirectoryEntry[]>(
|
const { data, error } = await useFetch<
|
||||||
getApiUrl(`warrens/${warrenId}/get${rest}`),
|
ApiResponse<{ files: DirectoryEntry[] }>
|
||||||
{
|
>(getApiUrl(`warrens/files`), {
|
||||||
method: 'GET',
|
method: 'POST',
|
||||||
}
|
headers: {
|
||||||
);
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
warrenId,
|
||||||
|
path: rest,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
if (entries.value == null) {
|
if (data.value == null) {
|
||||||
throw error.value?.name;
|
throw error.value?.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries.value;
|
const { files } = data.value.data;
|
||||||
|
|
||||||
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createDirectory(
|
export async function createDirectory(
|
||||||
@@ -57,17 +66,23 @@ export async function createDirectory(
|
|||||||
let [warrenId, rest] = splitOnce(path, '/');
|
let [warrenId, rest] = splitOnce(path, '/');
|
||||||
|
|
||||||
if (rest == null) {
|
if (rest == null) {
|
||||||
rest = '';
|
rest = '/';
|
||||||
} else {
|
} else {
|
||||||
rest += '/';
|
rest = '/' + rest + '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
const { status } = await useFetch(
|
rest += directoryName;
|
||||||
getApiUrl(`warrens/${warrenId}/create/${rest}${directoryName}`),
|
|
||||||
{
|
const { status } = await useFetch(getApiUrl(`warrens/files/directory`), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}
|
headers: {
|
||||||
);
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
warrenId,
|
||||||
|
path: rest,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
if (status.value !== 'success') {
|
if (status.value !== 'success') {
|
||||||
toast.error('Directory', {
|
toast.error('Directory', {
|
||||||
@@ -87,44 +102,93 @@ export async function createDirectory(
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteWarrenEntry(
|
export async function deleteWarrenDirectory(
|
||||||
path: string,
|
path: string,
|
||||||
directoryName: string,
|
directoryName: string
|
||||||
fileType: FileType
|
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
// eslint-disable-next-line prefer-const
|
// eslint-disable-next-line prefer-const
|
||||||
let [warrenId, rest] = splitOnce(path, '/');
|
let [warrenId, rest] = splitOnce(path, '/');
|
||||||
|
|
||||||
if (rest == null) {
|
if (rest == null) {
|
||||||
rest = '';
|
rest = '/';
|
||||||
} else {
|
} else {
|
||||||
rest = '/' + rest;
|
rest = '/' + rest + '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
const { status } = await useFetch(
|
rest += directoryName;
|
||||||
getApiUrl(
|
|
||||||
`warrens/${warrenId}/delete${rest}/${directoryName}?fileType=${fileType}`
|
|
||||||
),
|
|
||||||
{
|
|
||||||
method: 'DELETE',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const toastTitle = fileType.slice(0, 1).toUpperCase() + fileType.slice(1);
|
const { status } = await useFetch(getApiUrl(`warrens/files/directory`), {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
warrenId,
|
||||||
|
path: rest,
|
||||||
|
force: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const TOAST_TITLE = 'Directory';
|
||||||
|
|
||||||
if (status.value !== 'success') {
|
if (status.value !== 'success') {
|
||||||
toast.error(toastTitle, {
|
toast.error(TOAST_TITLE, {
|
||||||
id: 'DELETE_DIRECTORY_TOAST',
|
id: 'DELETE_DIRECTORY_TOAST',
|
||||||
description: `Failed to delete ${fileType}`,
|
description: `Failed to delete directory`,
|
||||||
});
|
});
|
||||||
return { success: false };
|
return { success: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
await refreshNuxtData('current-directory');
|
await refreshNuxtData('current-directory');
|
||||||
|
|
||||||
toast.success(toastTitle, {
|
toast.success(TOAST_TITLE, {
|
||||||
id: 'DELETE_DIRECTORY_TOAST',
|
id: 'DELETE_DIRECTORY_TOAST',
|
||||||
description: `Successfully deleted ${fileType}: ${directoryName}`,
|
description: `Successfully deleted ${directoryName}`,
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWarrenFile(
|
||||||
|
path: string,
|
||||||
|
fileName: string
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
// eslint-disable-next-line prefer-const
|
||||||
|
let [warrenId, rest] = splitOnce(path, '/');
|
||||||
|
|
||||||
|
if (rest == null) {
|
||||||
|
rest = '/';
|
||||||
|
} else {
|
||||||
|
rest = '/' + rest + '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
rest += fileName;
|
||||||
|
|
||||||
|
const { status } = await useFetch(getApiUrl(`warrens/files/file`), {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
warrenId,
|
||||||
|
path: rest,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const TOAST_TITLE = 'File';
|
||||||
|
|
||||||
|
if (status.value !== 'success') {
|
||||||
|
toast.error(TOAST_TITLE, {
|
||||||
|
id: 'DELETE_FILE_TOAST',
|
||||||
|
description: `Failed to delete file`,
|
||||||
|
});
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshNuxtData('current-directory');
|
||||||
|
|
||||||
|
toast.success(TOAST_TITLE, {
|
||||||
|
id: 'DELETE_FILE_TOAST',
|
||||||
|
description: `Successfully deleted ${fileName}`,
|
||||||
});
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -134,22 +198,17 @@ export async function uploadToWarren(
|
|||||||
files: FileList,
|
files: FileList,
|
||||||
onProgress: ((loaded: number, total: number) => void) | undefined
|
onProgress: ((loaded: number, total: number) => void) | undefined
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
const body = new FormData();
|
|
||||||
for (const file of files) {
|
|
||||||
body.append('files', file);
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line prefer-const
|
// eslint-disable-next-line prefer-const
|
||||||
let [warrenId, rest] = splitOnce(path, '/');
|
let [warrenId, rest] = splitOnce(path, '/');
|
||||||
|
|
||||||
if (rest == null) {
|
if (rest == null) {
|
||||||
rest = '';
|
rest = '/';
|
||||||
} else {
|
} else {
|
||||||
rest = '/' + rest;
|
rest = '/' + rest;
|
||||||
}
|
}
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', getApiUrl(`warrens/${warrenId}/upload${rest}`));
|
xhr.open('POST', getApiUrl(`warrens/files/upload`));
|
||||||
xhr.upload.onprogress = (e) => {
|
xhr.upload.onprogress = (e) => {
|
||||||
onProgress?.(e.loaded, e.total);
|
onProgress?.(e.loaded, e.total);
|
||||||
};
|
};
|
||||||
@@ -160,7 +219,7 @@ export async function uploadToWarren(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (xhr.status === 200) {
|
if (xhr.status >= 200 && xhr.status <= 300) {
|
||||||
res();
|
res();
|
||||||
} else {
|
} else {
|
||||||
rej();
|
rej();
|
||||||
@@ -168,6 +227,13 @@ export async function uploadToWarren(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const body = new FormData();
|
||||||
|
body.set('warrenId', warrenId);
|
||||||
|
body.set('path', rest);
|
||||||
|
for (const file of files) {
|
||||||
|
body.append('files', file);
|
||||||
|
}
|
||||||
|
|
||||||
xhr.send(body);
|
xhr.send(body);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
4
frontend/types/api.ts
Normal file
4
frontend/types/api.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export type ApiResponse<T> = {
|
||||||
|
status: number;
|
||||||
|
data: T;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user