diff --git a/crates/api/.gitignore b/crates/api/.gitignore new file mode 100644 index 0000000..a72e83b --- /dev/null +++ b/crates/api/.gitignore @@ -0,0 +1,6 @@ +*.sqlite +*.log +.env +*.db +*.db-shm +*.db-wal \ No newline at end of file diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs index 23d7b19..9bb6fc3 100644 --- a/crates/api/src/main.rs +++ b/crates/api/src/main.rs @@ -1,9 +1,9 @@ mod routes; use axum::{Router, routing::{get, post}}; -use common::SongService; +use common::{SongSearchService, SongService}; use persistence::SqliteRepositoryFactory; -use routes::songs::{create_song, delete_song, get_song, list_songs}; +use routes::songs::{create_song, delete_song, get_song, list_songs, update_song}; use routes::tabs::{AppState, parse_tab}; use std::sync::Arc; use tower_http::cors::{Any, CorsLayer}; @@ -18,12 +18,14 @@ async fn main() { let repo = SqliteRepositoryFactory::create(&database_url) .await .expect("failed to connect to database"); - let songs = SongService::new(Box::new(repo)); + let songs = SongService::new(Box::new(repo.clone())); + let search = SongSearchService::new(Box::new(repo)); let state = Arc::new(AppState { fetcher: Box::new(UgTabFetcher::new()), parser: Box::new(UgHtmlParser), songs, + search, }); let cors = CorsLayer::new() @@ -34,7 +36,7 @@ async fn main() { 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)) + .route("/songs/{id}", get(get_song).delete(delete_song).patch(update_song)) .layer(cors) .with_state(state); diff --git a/crates/api/src/routes/songs.rs b/crates/api/src/routes/songs.rs index 608596b..4b45f6c 100644 --- a/crates/api/src/routes/songs.rs +++ b/crates/api/src/routes/songs.rs @@ -1,12 +1,18 @@ use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, http::StatusCode, Json, }; use domain::RepositoryError; +use serde::Deserialize; use std::sync::Arc; use uuid::Uuid; +#[derive(Deserialize)] +pub struct ListQuery { + pub q: Option, +} + use crate::routes::tabs::{AppState, ErrorResponse, ParseRequest, resolve_html}; pub async fn create_song( @@ -30,11 +36,24 @@ pub async fn create_song( pub async fn list_songs( State(state): State>, + Query(params): Query, ) -> Result>, (StatusCode, Json)> { - let songs = state.songs.list().await.map_err(|e| { - (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() })) - })?; - Ok(Json(songs)) + let result = if let Some(q) = params.q.filter(|s| !s.is_empty()) { + state.search.search(&q).await + } else { + state.songs.list().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>, + Path(_id): Path, + Json(_body): Json, +) -> StatusCode { + StatusCode::NOT_IMPLEMENTED } pub async fn get_song( diff --git a/crates/api/src/routes/tabs.rs b/crates/api/src/routes/tabs.rs index cd336db..badb637 100644 --- a/crates/api/src/routes/tabs.rs +++ b/crates/api/src/routes/tabs.rs @@ -7,6 +7,7 @@ pub struct AppState { pub fetcher: Box, pub parser: Box, pub songs: common::SongService, + pub search: common::SongSearchService, } #[derive(Deserialize)] diff --git a/crates/common/.gitignore b/crates/common/.gitignore new file mode 100644 index 0000000..ffa3bbd --- /dev/null +++ b/crates/common/.gitignore @@ -0,0 +1 @@ +Cargo.lock \ No newline at end of file diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index ecbd84c..737e1cd 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,4 +1,4 @@ -use domain::{RepositoryError, Song, SongRepositoryPort, SongSummary, StoredSong}; +use domain::{RepositoryError, Song, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong}; use uuid::Uuid; pub struct SongService { @@ -26,3 +26,17 @@ impl SongService { self.repo.delete(id).await } } + +pub struct SongSearchService { + search: Box, +} + +impl SongSearchService { + pub fn new(search: Box) -> Self { + Self { search } + } + + pub async fn search(&self, query: &str) -> Result, domain::RepositoryError> { + self.search.search(query).await + } +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index f0ee140..c7fcb18 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -9,5 +9,5 @@ pub use chord::Chord; pub use song::{ChordPosition, LyricLine, Section, SectionKind, SongMeta, Song}; pub use song::{song_preview_chords, StoredSong, SongSummary}; pub use ports::{FetchError, ParseError, TabFetcherPort, TabParserPort, TabSource}; -pub use ports::{RepositoryError, SongRepositoryPort}; +pub use ports::{RepositoryError, SongRepositoryPort, SongSearchPort}; pub use transposer::{ChordTransposer, TransposeError}; diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 7a358c2..8ff560f 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -54,3 +54,8 @@ pub trait SongRepositoryPort: Send + Sync { async fn get(&self, id: Uuid) -> Result, RepositoryError>; async fn delete(&self, id: Uuid) -> Result<(), RepositoryError>; } + +#[async_trait] +pub trait SongSearchPort: Send + Sync { + async fn search(&self, query: &str) -> Result, RepositoryError>; +} diff --git a/crates/infrastructure/persistence/src/lib.rs b/crates/infrastructure/persistence/src/lib.rs index adf9d52..c789a47 100644 --- a/crates/infrastructure/persistence/src/lib.rs +++ b/crates/infrastructure/persistence/src/lib.rs @@ -1,11 +1,12 @@ use async_trait::async_trait; use domain::{ - RepositoryError, Song, SongMeta, SongRepositoryPort, SongSummary, StoredSong, + RepositoryError, Song, SongMeta, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong, song_preview_chords, }; use sqlx::SqlitePool; use uuid::Uuid; +#[derive(Clone)] pub struct SqliteSongRepository { pool: SqlitePool, } @@ -122,6 +123,43 @@ impl SongRepositoryPort for SqliteSongRepository { } } +#[async_trait] +impl SongSearchPort for SqliteSongRepository { + async fn search(&self, query: &str) -> Result, RepositoryError> { + let pattern = format!("%{}%", query); + let rows = sqlx::query_as::<_, SongRow>( + "SELECT id, title, artist, original_key, preview_chords, body FROM songs \ + WHERE title LIKE ? OR artist LIKE ? ORDER BY created_at DESC" + ) + .bind(&pattern) + .bind(&pattern) + .fetch_all(&self.pool) + .await + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + + rows.into_iter() + .map(|row| { + let id = Uuid::parse_str(&row.id) + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + let preview_chords: Vec = serde_json::from_str(&row.preview_chords) + .map_err(|e| RepositoryError::Internal(e.to_string()))?; + Ok(SongSummary { + id, + meta: SongMeta { + title: row.title, + artist: row.artist, + original_key: row.original_key, + capo: None, + tuning: None, + tempo: None, + }, + preview_chords, + }) + }) + .collect() + } +} + pub struct SqliteRepositoryFactory; impl SqliteRepositoryFactory {