From 8c0824c67c4b142d1266d33d9517c8a0a1da3cb9 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Wed, 8 Apr 2026 03:07:28 +0200 Subject: [PATCH] feat(api): add song CRUD routes, wire SongService into AppState --- crates/api/src/main.rs | 15 +++++++- crates/api/src/routes/mod.rs | 1 + crates/api/src/routes/songs.rs | 70 ++++++++++++++++++++++++++++++++++ crates/api/src/routes/tabs.rs | 31 +++++++-------- 4 files changed, 101 insertions(+), 16 deletions(-) create mode 100644 crates/api/src/routes/songs.rs diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs index 18395f7..23d7b19 100644 --- a/crates/api/src/main.rs +++ b/crates/api/src/main.rs @@ -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); diff --git a/crates/api/src/routes/mod.rs b/crates/api/src/routes/mod.rs index 4b2ad05..6876366 100644 --- a/crates/api/src/routes/mod.rs +++ b/crates/api/src/routes/mod.rs @@ -1 +1,2 @@ +pub mod songs; pub mod tabs; diff --git a/crates/api/src/routes/songs.rs b/crates/api/src/routes/songs.rs new file mode 100644 index 0000000..608596b --- /dev/null +++ b/crates/api/src/routes/songs.rs @@ -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>, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + 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>, +) -> 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)) +} + +pub async fn get_song( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + 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>, + Path(id): Path, +) -> Result)> { + 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() }))), + } +} diff --git a/crates/api/src/routes/tabs.rs b/crates/api/src/routes/tabs.rs index fb2aa6e..cd336db 100644 --- a/crates/api/src/routes/tabs.rs +++ b/crates/api/src/routes/tabs.rs @@ -6,6 +6,7 @@ use std::{path::PathBuf, sync::Arc}; pub struct AppState { pub fetcher: Box, pub parser: Box, + pub songs: common::SongService, } #[derive(Deserialize)] @@ -19,13 +20,9 @@ pub struct ErrorResponse { pub error: String, } -pub async fn parse_tab( - State(state): State>, - Json(body): Json, -) -> Result, (StatusCode, Json)> { - 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 { + 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>, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + 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() }))