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;
|
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);
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
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;
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user