feat(api): add song CRUD routes, wire SongService into AppState
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
mod routes;
|
||||
|
||||
use axum::{Router, routing::post};
|
||||
use axum::{Router, routing::{get, post}};
|
||||
use common::SongService;
|
||||
use persistence::SqliteRepositoryFactory;
|
||||
use routes::songs::{create_song, delete_song, get_song, list_songs};
|
||||
use routes::tabs::{AppState, parse_tab};
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
@@ -10,9 +13,17 @@ use ug_parser::{UgHtmlParser, UgTabFetcher};
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let database_url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "sqlite://./pocket-chords.db".into());
|
||||
let repo = SqliteRepositoryFactory::create(&database_url)
|
||||
.await
|
||||
.expect("failed to connect to database");
|
||||
let songs = SongService::new(Box::new(repo));
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
fetcher: Box::new(UgTabFetcher::new()),
|
||||
parser: Box::new(UgHtmlParser),
|
||||
songs,
|
||||
});
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
@@ -22,6 +33,8 @@ 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))
|
||||
.layer(cors)
|
||||
.with_state(state);
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub mod songs;
|
||||
pub mod tabs;
|
||||
|
||||
70
crates/api/src/routes/songs.rs
Normal file
70
crates/api/src/routes/songs.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use domain::RepositoryError;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::routes::tabs::{AppState, ErrorResponse, ParseRequest, resolve_html};
|
||||
|
||||
pub async fn create_song(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<ParseRequest>,
|
||||
) -> Result<Json<domain::StoredSong>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let html = resolve_html(&state, body).await.map_err(|e| {
|
||||
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))
|
||||
})?;
|
||||
|
||||
let song = state.parser.parse(&html).map_err(|e| {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, Json(ErrorResponse { error: e.to_string() }))
|
||||
})?;
|
||||
|
||||
let stored = state.songs.save(&song).await.map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))
|
||||
})?;
|
||||
|
||||
Ok(Json(stored))
|
||||
}
|
||||
|
||||
pub async fn list_songs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> 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))
|
||||
}
|
||||
|
||||
pub async fn get_song(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let uuid = Uuid::parse_str(&id).map_err(|_| {
|
||||
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() }))
|
||||
})?;
|
||||
|
||||
match state.songs.get(uuid).await {
|
||||
Ok(Some(song)) => Ok(Json(song)),
|
||||
Ok(None) => Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() }))),
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))),
|
||||
}
|
||||
}
|
||||
|
||||
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() }))
|
||||
})?;
|
||||
|
||||
match state.songs.delete(uuid).await {
|
||||
Ok(()) => Ok(StatusCode::NO_CONTENT),
|
||||
Err(RepositoryError::NotFound) => {
|
||||
Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() })))
|
||||
}
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))),
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use std::{path::PathBuf, sync::Arc};
|
||||
pub struct AppState {
|
||||
pub fetcher: Box<dyn TabFetcherPort>,
|
||||
pub parser: Box<dyn TabParserPort>,
|
||||
pub songs: common::SongService,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -19,13 +20,9 @@ pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
pub async fn parse_tab(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<ParseRequest>,
|
||||
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let html = if let Some(raw_html) = body.html {
|
||||
// Raw HTML provided directly (e.g. from browser file upload via FileReader)
|
||||
raw_html
|
||||
pub async fn resolve_html(state: &AppState, body: ParseRequest) -> Result<String, String> {
|
||||
if let Some(raw_html) = body.html {
|
||||
Ok(raw_html)
|
||||
} else if let Some(source) = body.source {
|
||||
let tab_source = if source.starts_with("file://") {
|
||||
let path = source.trim_start_matches("file://");
|
||||
@@ -33,15 +30,19 @@ pub async fn parse_tab(
|
||||
} else {
|
||||
TabSource::Url(source)
|
||||
};
|
||||
state.fetcher.fetch(tab_source).await.map_err(|e| {
|
||||
(StatusCode::BAD_GATEWAY, Json(ErrorResponse { error: e.to_string() }))
|
||||
})?
|
||||
state.fetcher.fetch(tab_source).await.map_err(|e| e.to_string())
|
||||
} else {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse { error: "Provide either 'source' or 'html'".into() }),
|
||||
));
|
||||
};
|
||||
Err("Provide either 'source' or 'html'".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn parse_tab(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<ParseRequest>,
|
||||
) -> Result<Json<domain::Song>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let html = resolve_html(&state, body).await.map_err(|e| {
|
||||
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))
|
||||
})?;
|
||||
|
||||
let song = state.parser.parse(&html).map_err(|e| {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, Json(ErrorResponse { error: e.to_string() }))
|
||||
|
||||
Reference in New Issue
Block a user