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:
2026-07-11 21:02:10 +02:00
parent a520251dab
commit d13df586dd
74 changed files with 1493 additions and 1076 deletions

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

View File

@@ -0,0 +1,2 @@
pub mod songs;
pub mod tabs;

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

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