refactor: DDD/CQRS architecture, unified crate layout
- crates: common→application, api→presentation, infrastructure/*→adapters/* - new crates: api-types, infra-wiring - domain: errors/, models/, value_objects/, ports/, services/ - application: CQRS use cases (songs/, tabs/) w/ commands, queries, deps - unified DomainError replaces RepositoryError - workspace deps, unused dep cleanup - fix: parse plain-text chord lines (UG drops spans mid-song) - tests extracted to separate modules (tests/ dirs)
This commit is contained in:
80
crates/presentation/src/main.rs
Normal file
80
crates/presentation/src/main.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
mod routes;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::songs::deps::{SongCommandDeps, SongQueryDeps};
|
||||
use application::tabs::deps::ParseTabDeps;
|
||||
use axum::{
|
||||
Router,
|
||||
http::HeaderValue,
|
||||
routing::{get, post},
|
||||
};
|
||||
use infra_wiring::{AppConfig, CorsOrigins};
|
||||
use routes::songs::{create_song, delete_song, get_song, list_songs, update_song};
|
||||
use routes::tabs::{AppState, parse_tab};
|
||||
use sqlite::SqliteRepositoryFactory;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use ug_parser::{UgHtmlParser, UgTabFetcher};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let config = AppConfig::from_env();
|
||||
tracing::info!(?config, "starting with config");
|
||||
|
||||
let repo = SqliteRepositoryFactory::create(&config.database_url)
|
||||
.await
|
||||
.expect("failed to connect to database");
|
||||
|
||||
let repo = Arc::new(repo);
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
song_commands: SongCommandDeps { repo: repo.clone() },
|
||||
song_queries: SongQueryDeps {
|
||||
repo: repo.clone(),
|
||||
search: repo.clone(),
|
||||
},
|
||||
tab_parser: ParseTabDeps {
|
||||
fetcher: Arc::new(UgTabFetcher::new()),
|
||||
parser: Arc::new(UgHtmlParser),
|
||||
},
|
||||
});
|
||||
|
||||
let cors = match config.cors_origins {
|
||||
CorsOrigins::Any => CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any),
|
||||
CorsOrigins::List(ref origins) => {
|
||||
let parsed: Vec<HeaderValue> = origins
|
||||
.iter()
|
||||
.map(|o| {
|
||||
o.parse()
|
||||
.unwrap_or_else(|_| panic!("invalid CORS origin: {o}"))
|
||||
})
|
||||
.collect();
|
||||
CorsLayer::new()
|
||||
.allow_origin(parsed)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any)
|
||||
}
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/tabs/parse", post(parse_tab))
|
||||
.route("/songs", post(create_song).get(list_songs))
|
||||
.route(
|
||||
"/songs/{id}",
|
||||
get(get_song).delete(delete_song).patch(update_song),
|
||||
)
|
||||
.layer(cors)
|
||||
.with_state(state);
|
||||
|
||||
let addr = config.bind_addr();
|
||||
let listener = tokio::net::TcpListener::bind(&addr)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("failed to bind {addr}: {e}"));
|
||||
tracing::info!("listening on {}", listener.local_addr().unwrap());
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
2
crates/presentation/src/routes/mod.rs
Normal file
2
crates/presentation/src/routes/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod songs;
|
||||
pub mod tabs;
|
||||
203
crates/presentation/src/routes/songs.rs
Normal file
203
crates/presentation/src/routes/songs.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
use api_types::{ErrorResponse, GetSongQuery, ListQuery, ParseRequest, UpdateSongRequest};
|
||||
use application::songs::commands::{DeleteSongCommand, SaveSongCommand, UpdateSongMetaCommand};
|
||||
use application::songs::queries::{ListSongsQuery, SearchSongsQuery};
|
||||
use application::tabs::commands::ParseTabCommand;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use domain::{ChordTransposer, DomainError, SortField, SortOrder};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::routes::tabs::AppState;
|
||||
|
||||
pub async fn create_song(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<ParseRequest>,
|
||||
) -> Result<Json<domain::StoredSong>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let cmd = ParseTabCommand {
|
||||
source: body.source,
|
||||
html: body.html,
|
||||
};
|
||||
|
||||
let song = application::tabs::parse_tab::execute(&state.tab_parser, cmd)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let cmd = SaveSongCommand { song };
|
||||
application::songs::save_song::execute(&state.song_commands, cmd)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_songs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<ListQuery>,
|
||||
) -> Result<Json<Vec<domain::SongSummary>>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let sort = match params.sort.as_deref() {
|
||||
Some("title") => SortField::Title,
|
||||
Some("artist") => SortField::Artist,
|
||||
_ => SortField::Date,
|
||||
};
|
||||
let order = match params.order.as_deref() {
|
||||
Some("asc") => SortOrder::Asc,
|
||||
_ => SortOrder::Desc,
|
||||
};
|
||||
|
||||
let result = if let Some(q) = params.q.filter(|s| !s.is_empty()) {
|
||||
let query = SearchSongsQuery {
|
||||
query: q,
|
||||
sort,
|
||||
order,
|
||||
};
|
||||
application::songs::search_songs::execute(&state.song_queries, query).await
|
||||
} else {
|
||||
let query = ListSongsQuery { sort, order };
|
||||
application::songs::list_songs::execute(&state.song_queries, query).await
|
||||
};
|
||||
|
||||
result.map(Json).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn update_song(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<UpdateSongRequest>,
|
||||
) -> Result<Json<domain::SongSummary>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let uuid = Uuid::parse_str(&id).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid ID".into(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let cmd = UpdateSongMetaCommand {
|
||||
id: uuid,
|
||||
title: body.title,
|
||||
artist: body.artist,
|
||||
original_key: body.original_key,
|
||||
};
|
||||
|
||||
application::songs::update_meta::execute(&state.song_commands, cmd)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|e| match e {
|
||||
DomainError::NotFound => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "Not found".into(),
|
||||
}),
|
||||
),
|
||||
e => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_song(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Query(params): Query<GetSongQuery>,
|
||||
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let uuid = Uuid::parse_str(&id).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid ID".into(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let query = application::songs::queries::GetSongQuery { id: uuid };
|
||||
let song = match application::songs::get_song::execute(&state.song_queries, query).await {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "Not found".into(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let song = if params.apply_capo.unwrap_or(false) {
|
||||
if let Some(capo) = song.meta.capo {
|
||||
ChordTransposer.transpose_song(&song, capo as i8)
|
||||
} else {
|
||||
song
|
||||
}
|
||||
} else {
|
||||
song
|
||||
};
|
||||
|
||||
Ok(Json(song))
|
||||
}
|
||||
|
||||
pub async fn delete_song(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
||||
let uuid = Uuid::parse_str(&id).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid ID".into(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let cmd = DeleteSongCommand { id: uuid };
|
||||
match application::songs::delete_song::execute(&state.song_commands, cmd).await {
|
||||
Ok(()) => Ok(StatusCode::NO_CONTENT),
|
||||
Err(DomainError::NotFound) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "Not found".into(),
|
||||
}),
|
||||
)),
|
||||
Err(e) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)),
|
||||
}
|
||||
}
|
||||
34
crates/presentation/src/routes/tabs.rs
Normal file
34
crates/presentation/src/routes/tabs.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use api_types::{ErrorResponse, ParseRequest};
|
||||
use application::songs::deps::{SongCommandDeps, SongQueryDeps};
|
||||
use application::tabs::commands::ParseTabCommand;
|
||||
use application::tabs::deps::ParseTabDeps;
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct AppState {
|
||||
pub song_commands: SongCommandDeps,
|
||||
pub song_queries: SongQueryDeps,
|
||||
pub tab_parser: ParseTabDeps,
|
||||
}
|
||||
|
||||
pub async fn parse_tab(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<ParseRequest>,
|
||||
) -> Result<Json<domain::models::Song>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let cmd = ParseTabCommand {
|
||||
source: body.source,
|
||||
html: body.html,
|
||||
};
|
||||
|
||||
application::tabs::parse_tab::execute(&state.tab_parser, cmd)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user