feat: add SongSearchService and GET /songs?q= search endpoint
This commit is contained in:
6
crates/api/.gitignore
vendored
Normal file
6
crates/api/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
*.sqlite
|
||||
*.log
|
||||
.env
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<Arc<AppState>>,
|
||||
Query(params): Query<ListQuery>,
|
||||
) -> Result<Json<Vec<domain::SongSummary>>, (StatusCode, Json<ErrorResponse>)> {
|
||||
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<Arc<AppState>>,
|
||||
Path(_id): Path<String>,
|
||||
Json(_body): Json<serde_json::Value>,
|
||||
) -> StatusCode {
|
||||
StatusCode::NOT_IMPLEMENTED
|
||||
}
|
||||
|
||||
pub async fn get_song(
|
||||
|
||||
@@ -7,6 +7,7 @@ pub struct AppState {
|
||||
pub fetcher: Box<dyn TabFetcherPort>,
|
||||
pub parser: Box<dyn TabParserPort>,
|
||||
pub songs: common::SongService,
|
||||
pub search: common::SongSearchService,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
1
crates/common/.gitignore
vendored
Normal file
1
crates/common/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Cargo.lock
|
||||
@@ -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<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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -54,3 +54,8 @@ pub trait SongRepositoryPort: Send + Sync {
|
||||
async fn get(&self, id: Uuid) -> Result<Option<Song>, 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>;
|
||||
}
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
impl SqliteRepositoryFactory {
|
||||
|
||||
Reference in New Issue
Block a user