feat: add SongSearchService and GET /songs?q= search endpoint

This commit is contained in:
2026-04-08 03:35:33 +02:00
parent c3b7cb78ab
commit 377fe957bc
9 changed files with 98 additions and 12 deletions

6
crates/api/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
*.sqlite
*.log
.env
*.db
*.db-shm
*.db-wal

View File

@@ -1,9 +1,9 @@
mod routes; mod routes;
use axum::{Router, routing::{get, post}}; use axum::{Router, routing::{get, post}};
use common::SongService; use common::{SongSearchService, SongService};
use persistence::SqliteRepositoryFactory; 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 routes::tabs::{AppState, parse_tab};
use std::sync::Arc; use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer}; use tower_http::cors::{Any, CorsLayer};
@@ -18,12 +18,14 @@ async fn main() {
let repo = SqliteRepositoryFactory::create(&database_url) let repo = SqliteRepositoryFactory::create(&database_url)
.await .await
.expect("failed to connect to database"); .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 { let state = Arc::new(AppState {
fetcher: Box::new(UgTabFetcher::new()), fetcher: Box::new(UgTabFetcher::new()),
parser: Box::new(UgHtmlParser), parser: Box::new(UgHtmlParser),
songs, songs,
search,
}); });
let cors = CorsLayer::new() let cors = CorsLayer::new()
@@ -34,7 +36,7 @@ async fn main() {
let app = Router::new() let app = Router::new()
.route("/tabs/parse", post(parse_tab)) .route("/tabs/parse", post(parse_tab))
.route("/songs", post(create_song).get(list_songs)) .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) .layer(cors)
.with_state(state); .with_state(state);

View File

@@ -1,12 +1,18 @@
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
Json, Json,
}; };
use domain::RepositoryError; use domain::RepositoryError;
use serde::Deserialize;
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
#[derive(Deserialize)]
pub struct ListQuery {
pub q: Option<String>,
}
use crate::routes::tabs::{AppState, ErrorResponse, ParseRequest, resolve_html}; use crate::routes::tabs::{AppState, ErrorResponse, ParseRequest, resolve_html};
pub async fn create_song( pub async fn create_song(
@@ -30,11 +36,24 @@ pub async fn create_song(
pub async fn list_songs( pub async fn list_songs(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Query(params): Query<ListQuery>,
) -> Result<Json<Vec<domain::SongSummary>>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<Vec<domain::SongSummary>>, (StatusCode, Json<ErrorResponse>)> {
let songs = state.songs.list().await.map_err(|e| { let result = if let Some(q) = params.q.filter(|s| !s.is_empty()) {
(StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() })) state.search.search(&q).await
})?; } else {
Ok(Json(songs)) 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<Arc<AppState>>,
Path(_id): Path<String>,
Json(_body): Json<serde_json::Value>,
) -> StatusCode {
StatusCode::NOT_IMPLEMENTED
} }
pub async fn get_song( pub async fn get_song(

View File

@@ -7,6 +7,7 @@ pub struct AppState {
pub fetcher: Box<dyn TabFetcherPort>, pub fetcher: Box<dyn TabFetcherPort>,
pub parser: Box<dyn TabParserPort>, pub parser: Box<dyn TabParserPort>,
pub songs: common::SongService, pub songs: common::SongService,
pub search: common::SongSearchService,
} }
#[derive(Deserialize)] #[derive(Deserialize)]

1
crates/common/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
Cargo.lock

View File

@@ -1,4 +1,4 @@
use domain::{RepositoryError, Song, SongRepositoryPort, SongSummary, StoredSong}; use domain::{RepositoryError, Song, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong};
use uuid::Uuid; use uuid::Uuid;
pub struct SongService { pub struct SongService {
@@ -26,3 +26,17 @@ impl SongService {
self.repo.delete(id).await self.repo.delete(id).await
} }
} }
pub struct SongSearchService {
search: Box<dyn SongSearchPort>,
}
impl SongSearchService {
pub fn new(search: Box<dyn SongSearchPort>) -> Self {
Self { search }
}
pub async fn search(&self, query: &str) -> Result<Vec<domain::SongSummary>, domain::RepositoryError> {
self.search.search(query).await
}
}

View File

@@ -9,5 +9,5 @@ pub use chord::Chord;
pub use song::{ChordPosition, LyricLine, Section, SectionKind, SongMeta, Song}; pub use song::{ChordPosition, LyricLine, Section, SectionKind, SongMeta, Song};
pub use song::{song_preview_chords, StoredSong, SongSummary}; pub use song::{song_preview_chords, StoredSong, SongSummary};
pub use ports::{FetchError, ParseError, TabFetcherPort, TabParserPort, TabSource}; pub use ports::{FetchError, ParseError, TabFetcherPort, TabParserPort, TabSource};
pub use ports::{RepositoryError, SongRepositoryPort}; pub use ports::{RepositoryError, SongRepositoryPort, SongSearchPort};
pub use transposer::{ChordTransposer, TransposeError}; pub use transposer::{ChordTransposer, TransposeError};

View File

@@ -54,3 +54,8 @@ pub trait SongRepositoryPort: Send + Sync {
async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError>; async fn get(&self, id: Uuid) -> Result<Option<Song>, RepositoryError>;
async fn delete(&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<Vec<SongSummary>, RepositoryError>;
}

View File

@@ -1,11 +1,12 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
RepositoryError, Song, SongMeta, SongRepositoryPort, SongSummary, StoredSong, RepositoryError, Song, SongMeta, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong,
song_preview_chords, song_preview_chords,
}; };
use sqlx::SqlitePool; use sqlx::SqlitePool;
use uuid::Uuid; use uuid::Uuid;
#[derive(Clone)]
pub struct SqliteSongRepository { pub struct SqliteSongRepository {
pool: SqlitePool, pool: SqlitePool,
} }
@@ -122,6 +123,43 @@ impl SongRepositoryPort for SqliteSongRepository {
} }
} }
#[async_trait]
impl SongSearchPort for SqliteSongRepository {
async fn search(&self, query: &str) -> Result<Vec<SongSummary>, 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<String> = 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; pub struct SqliteRepositoryFactory;
impl SqliteRepositoryFactory { impl SqliteRepositoryFactory {