From e95443fed8b215627232db6cd04a240e87a52042 Mon Sep 17 00:00:00 2001 From: n4mlz Date: Tue, 1 Sep 2026 12:38:36 +0900 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E7=AE=A1=E7=90=86=E8=80=85?= =?UTF-8?q?=E5=90=91=E3=81=91=E6=A5=BD=E6=9B=B2=E8=A9=B3=E7=B4=B0=20API=20?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/domain/src/repository/music.rs | 7 ++++ crates/infrastructure/src/music/mod.rs | 9 +++++ crates/infrastructure/src/music/read.rs | 28 ++++++++++++++ crates/presentation/src/error/convert.rs | 4 ++ crates/presentation/src/route/admin.rs | 10 ++++- crates/presentation/src/route/mod.rs | 1 + crates/usecase/src/music/mod.rs | 47 ++++++++++++++++++++++++ docs/openapi.yaml | 30 +++++++++++++++ 8 files changed, 135 insertions(+), 1 deletion(-) diff --git a/crates/domain/src/repository/music.rs b/crates/domain/src/repository/music.rs index e26687a3..8c2d2ee4 100644 --- a/crates/domain/src/repository/music.rs +++ b/crates/domain/src/repository/music.rs @@ -8,6 +8,8 @@ use crate::entity::{music::Music, sheet::Sheet}; #[derive(Debug, Error)] pub enum MusicRepositoryError { + #[error("Music not found: {0}")] + NotFound(String), #[error(transparent)] InternalError(#[from] anyhow::Error), } @@ -47,4 +49,9 @@ pub trait MusicRepository: Send + Sync { cursor: Option, limit: u64, ) -> impl Future> + Send; + + fn find_with_sheets( + &self, + music_id: &str, + ) -> impl Future> + Send; } diff --git a/crates/infrastructure/src/music/mod.rs b/crates/infrastructure/src/music/mod.rs index c2850222..73e379b3 100644 --- a/crates/infrastructure/src/music/mod.rs +++ b/crates/infrastructure/src/music/mod.rs @@ -37,4 +37,13 @@ impl MusicRepository for MusicRepositoryImpl { debug!(limit, "Loading a page of music metadata via SeaORM"); read::list_with_sheets_page(self.db.as_ref(), cursor, limit).await } + + #[instrument(skip(self), fields(music_id = %music_id))] + async fn find_with_sheets( + &self, + music_id: &str, + ) -> Result { + debug!("Loading music metadata by id via SeaORM"); + read::find_with_sheets(self.db.as_ref(), music_id).await + } } diff --git a/crates/infrastructure/src/music/read.rs b/crates/infrastructure/src/music/read.rs index 36c8e583..b7869e7e 100644 --- a/crates/infrastructure/src/music/read.rs +++ b/crates/infrastructure/src/music/read.rs @@ -95,3 +95,31 @@ pub async fn list_with_sheets_page( Ok(MusicListPage { items, next_cursor }) } + +/// Loads one music entry with all related sheets. +pub async fn find_with_sheets( + db: &DbConn, + music_id: &str, +) -> Result { + let id = uuid::Uuid::parse_str(music_id) + .map_err(|error| MusicRepositoryError::InternalError(AnyError::from(error)))?; + let result = entities::musics::Entity::find_by_id(id) + .find_with_related(entities::sheets::Entity) + .all(db) + .await + .map_err(|err| { + error!(error = %err, music_id, "Failed to fetch music by id"); + MusicRepositoryError::InternalError(AnyError::from(err)) + })? + .into_iter() + .next(); + + let Some((music_model, sheet_models)) = result else { + return Err(MusicRepositoryError::NotFound(music_id.to_owned())); + }; + + Ok(MusicWithSheets::new( + adapter::convert_music(music_model)?, + adapter::convert_sheets(sheet_models)?, + )) +} diff --git a/crates/presentation/src/error/convert.rs b/crates/presentation/src/error/convert.rs index 83fe031b..c8553aca 100644 --- a/crates/presentation/src/error/convert.rs +++ b/crates/presentation/src/error/convert.rs @@ -70,6 +70,10 @@ impl From for AppError { impl From for AppError { fn from(error: MusicRepositoryError) -> Self { match error { + MusicRepositoryError::NotFound(id) => AppError { + status_code: axum::http::StatusCode::NOT_FOUND, + message: format!("Music not found: {id}"), + }, MusicRepositoryError::InternalError(err) => AppError { status_code: axum::http::StatusCode::INTERNAL_SERVER_ERROR, message: err.to_string(), diff --git a/crates/presentation/src/route/admin.rs b/crates/presentation/src/route/admin.rs index bb128630..a1e52a01 100644 --- a/crates/presentation/src/route/admin.rs +++ b/crates/presentation/src/route/admin.rs @@ -1,6 +1,6 @@ use axum::{ Json, - extract::{Query, State}, + extract::{Path, Query, State}, }; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::{DateTime, Utc}; @@ -58,6 +58,14 @@ pub async fn handle_list_musics( Ok(Json(MusicListResponse { items, next_cursor })) } +pub async fn handle_get_music( + State(state): State, + Path(music_id): Path, +) -> Result, AppError> { + let music = state.usecases.music.find_by_id(music_id).await?; + Ok(Json(SyncItemResponse::from(music))) +} + fn encode_cursor(cursor: MusicListCursor) -> Result { let payload = CursorPayload { registration_date: cursor.registration_date.to_rfc3339(), diff --git a/crates/presentation/src/route/mod.rs b/crates/presentation/src/route/mod.rs index 7e14a003..a1b38da6 100644 --- a/crates/presentation/src/route/mod.rs +++ b/crates/presentation/src/route/mod.rs @@ -45,6 +45,7 @@ pub fn create_app(state: State, authenticator: Option) -> Router let health = Router::new().route("/", get(|| async { "OK" })); let admin_routes = Router::new() .route("/musics", get(admin::handle_list_musics)) + .route("/musics/{musicId}", get(admin::handle_get_music)) .route("/db/synchronize", post(admin::handle_db_synchronization)); let private_routes = Router::new() diff --git a/crates/usecase/src/music/mod.rs b/crates/usecase/src/music/mod.rs index 3d68e11d..cae4a2ce 100644 --- a/crates/usecase/src/music/mod.rs +++ b/crates/usecase/src/music/mod.rs @@ -43,6 +43,18 @@ impl MusicUsecase { next_cursor: page.next_cursor, }) } + + pub async fn find_by_id( + &self, + music_id: String, + ) -> Result { + let music = self + .repositories + .music() + .find_with_sheets(&music_id) + .await?; + Ok(music.into()) + } } pub struct MusicPageDto { @@ -121,4 +133,39 @@ mod tests { assert_eq!(result[0].sheets.len(), 1); assert_eq!(result[0].sheets[0].id, "sheet-1"); } + + #[tokio::test] + async fn find_by_id_returns_entry() { + let mut music_repo = MockMusicRepository::new(); + music_repo + .expect_find_with_sheets() + .withf(|music_id| music_id == "music-1") + .returning(|_| { + let music = Music::new( + "music-1".to_owned(), + "Song".to_owned(), + "Artist".to_owned(), + 135.5, + Genre::ORIGINAL, + "jacket.png".to_owned(), + Utc::now(), + false, + ); + Box::pin(async move { Ok(MusicWithSheets::new(music, Vec::new())) }) + }); + + let repositories = MockRepositories { + user: MockUserRepository::new(), + record: MockRecordRepository::new(), + music: music_repo, + }; + let usecase = MusicUsecase::new(Arc::new(repositories)); + + let result = usecase + .find_by_id("music-1".to_owned()) + .await + .expect("should succeed"); + assert_eq!(result.music.id, "music-1"); + assert!(result.sheets.is_empty()); + } } diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 09d67dc9..452a699d 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -183,6 +183,36 @@ paths: description: Forbidden - The authenticated user is not an administrator "500": description: Internal server error + /admin/musics/{musicId}: + get: + tags: + - admin + summary: 楽曲と譜面の詳細を取得 + security: + - userAuth: [] + parameters: + - name: musicId + in: path + description: 楽曲のID + required: true + schema: + type: string + format: uuid + responses: + "200": + description: 楽曲と譜面の詳細 + content: + application/json: + schema: + $ref: "#/components/schemas/musicWithSheets" + "401": + description: Unauthorized - Invalid access token + "403": + description: Forbidden - The authenticated user is not an administrator + "404": + description: Not found - Music not found + "500": + description: Internal server error /users/{userId}/credits/increment: post: tags: