Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ members = [
[workspace.dependencies]
anyhow = "1.0.95"
axum = "0.8.4"
base64 = "0.22.1"
bigdecimal = "0.4"
chrono = { version = "0.4.39", features = ["serde", "clock"] }
domain = { path = "crates/domain" }
Expand Down Expand Up @@ -46,3 +47,4 @@ tower-http = { version = "0.6.6", features = ["trace", "cors"] }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "fmt"] }
usecase = { path = "crates/usecase" }
uuid = "1.18.1"
19 changes: 19 additions & 0 deletions crates/domain/src/repository/music.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::future::Future;

use chrono::{DateTime, Utc};
use mockall::automock;
use thiserror::Error;

Expand All @@ -17,6 +18,18 @@ pub struct MusicWithSheets {
pub sheets: Vec<Sheet>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MusicListCursor {
pub registration_date: DateTime<Utc>,
pub id: String,
}

#[derive(Debug)]
pub struct MusicListPage {
pub items: Vec<MusicWithSheets>,
pub next_cursor: Option<MusicListCursor>,
}

impl MusicWithSheets {
pub fn new(music: Music, sheets: Vec<Sheet>) -> Self {
Self { music, sheets }
Expand All @@ -28,4 +41,10 @@ pub trait MusicRepository: Send + Sync {
fn list_with_sheets(
&self,
) -> impl Future<Output = Result<Vec<MusicWithSheets>, MusicRepositoryError>> + Send;

fn list_with_sheets_page(
&self,
cursor: Option<MusicListCursor>,
limit: u64,
) -> impl Future<Output = Result<MusicListPage, MusicRepositoryError>> + Send;
}
1 change: 1 addition & 0 deletions crates/infrastructure/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ chrono.workspace = true
domain.workspace = true
sea-orm.workspace = true
tracing.workspace = true
uuid.workspace = true

[dev-dependencies]
domain = { workspace = true, features = ["test-support"] }
Expand Down
14 changes: 13 additions & 1 deletion crates/infrastructure/src/music/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ mod read;

use std::sync::Arc;

use domain::repository::music::{MusicRepository, MusicRepositoryError, MusicWithSheets};
use domain::repository::music::{
MusicListCursor, MusicListPage, MusicRepository, MusicRepositoryError, MusicWithSheets,
};
use sea_orm::DbConn;
use tracing::{debug, info, instrument};

Expand All @@ -25,4 +27,14 @@ impl MusicRepository for MusicRepositoryImpl {
info!(count = musics.len(), "Music metadata loaded");
Ok(musics)
}

#[instrument(skip(self))]
async fn list_with_sheets_page(
&self,
cursor: Option<MusicListCursor>,
limit: u64,
) -> Result<MusicListPage, MusicRepositoryError> {
debug!(limit, "Loading a page of music metadata via SeaORM");
read::list_with_sheets_page(self.db.as_ref(), cursor, limit).await
}
}
67 changes: 65 additions & 2 deletions crates/infrastructure/src/music/read.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use anyhow::Error as AnyError;
use domain::repository::music::{MusicRepositoryError, MusicWithSheets};
use sea_orm::{DbConn, EntityTrait, QueryOrder};
use chrono::Utc;
use domain::repository::music::{
MusicListCursor, MusicListPage, MusicRepositoryError, MusicWithSheets,
};
use sea_orm::{ColumnTrait, Condition, DbConn, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use tracing::{debug, error};

use super::adapter;
Expand Down Expand Up @@ -32,3 +35,63 @@ pub async fn list_with_sheets(db: &DbConn) -> Result<Vec<MusicWithSheets>, Music

Ok(musics)
}

/// Loads one ordered page of music and its sheets directly from the database.
pub async fn list_with_sheets_page(
db: &DbConn,
cursor: Option<MusicListCursor>,
limit: u64,
) -> Result<MusicListPage, MusicRepositoryError> {
debug!(
limit,
has_cursor = cursor.is_some(),
"Querying a page of musics with related sheets"
);

let mut query = entities::musics::Entity::find();
if let Some(cursor) = cursor {
let cursor_id = uuid::Uuid::parse_str(&cursor.id)
.map_err(|error| MusicRepositoryError::InternalError(AnyError::from(error)))?;
query = query.filter(
Condition::any()
.add(entities::musics::Column::RegistrationDate.gt(cursor.registration_date))
.add(
Condition::all()
.add(
entities::musics::Column::RegistrationDate.eq(cursor.registration_date),
)
.add(entities::musics::Column::Id.gt(cursor_id)),
),
);
}

let mut models = query
.order_by_asc(entities::musics::Column::RegistrationDate)
.order_by_asc(entities::musics::Column::Id)
.limit(limit + 1)
.find_with_related(entities::sheets::Entity)
.all(db)
.await
.map_err(|err| {
error!(error = %err, "Failed to fetch a page of musics");
MusicRepositoryError::InternalError(AnyError::from(err))
})?;

let next_cursor = if models.len() > limit as usize {
models.pop().map(|(model, _)| MusicListCursor {
registration_date: model.registration_date.with_timezone(&Utc),
id: model.id.to_string(),
})
} else {
None
};

let mut items = Vec::with_capacity(models.len());
for (music_model, sheet_models) in models {
let music = adapter::convert_music(music_model)?;
let sheets = adapter::convert_sheets(sheet_models)?;
items.push(MusicWithSheets::new(music, sheets));
}

Ok(MusicListPage { items, next_cursor })
}
3 changes: 3 additions & 0 deletions crates/presentation/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ edition = "2024"
[dependencies]
anyhow.workspace = true
axum.workspace = true
base64.workspace = true
chrono.workspace = true
domain.workspace = true
dotenvy.workspace = true
infrastructure.workspace = true
Expand All @@ -19,6 +21,7 @@ tower-http.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
usecase.workspace = true
uuid.workspace = true

[dev-dependencies]
chrono.workspace = true
Expand Down
5 changes: 5 additions & 0 deletions crates/presentation/src/error/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod convert;

#[derive(Debug)]
pub struct AppError {
pub status_code: axum::http::StatusCode,
pub message: String,
Expand All @@ -19,6 +20,10 @@ impl AppError {
"Resource not found".to_owned(),
)
}

pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(axum::http::StatusCode::BAD_REQUEST, message.into())
}
}

impl axum::response::IntoResponse for AppError {
Expand Down
111 changes: 108 additions & 3 deletions crates/presentation/src/route/admin.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,36 @@
use axum::{Json, extract::State};
use serde::Serialize;
use axum::{
Json,
extract::{Query, State},
};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, Utc};
use domain::repository::music::MusicListCursor;
use serde::{Deserialize, Serialize};
use tracing::info;

use crate::error::AppError;
use crate::{error::AppError, model::sync::SyncItemResponse};

const DEFAULT_PAGE_LIMIT: u64 = 50;
const MAX_PAGE_LIMIT: u64 = 100;

#[derive(Deserialize)]
pub struct MusicListQuery {
pub cursor: Option<String>,
pub limit: Option<u64>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MusicListResponse {
pub items: Vec<SyncItemResponse>,
pub next_cursor: Option<String>,
}

#[derive(Deserialize, Serialize)]
struct CursorPayload {
registration_date: String,
id: String,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
Expand All @@ -11,6 +39,57 @@ pub struct DbSynchronizationResponse {
pub updated_ratings: u64,
}

pub async fn handle_list_musics(
State(state): State<crate::state::State>,
Query(query): Query<MusicListQuery>,
) -> Result<Json<MusicListResponse>, AppError> {
let limit = query.limit.unwrap_or(DEFAULT_PAGE_LIMIT);
if !(1..=MAX_PAGE_LIMIT).contains(&limit) {
return Err(AppError::bad_request(format!(
"limit must be between 1 and {MAX_PAGE_LIMIT}"
)));
}

let cursor = query.cursor.as_deref().map(decode_cursor).transpose()?;
let page = state.usecases.music.list_page(cursor, limit).await?;
let next_cursor = page.next_cursor.map(encode_cursor).transpose()?;
let items = page.items.into_iter().map(SyncItemResponse::from).collect();

Ok(Json(MusicListResponse { items, next_cursor }))
}

fn encode_cursor(cursor: MusicListCursor) -> Result<String, AppError> {
let payload = CursorPayload {
registration_date: cursor.registration_date.to_rfc3339(),
id: cursor.id,
};
let bytes = serde_json::to_vec(&payload).map_err(|error| {
AppError::new(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
error.to_string(),
)
})?;
Ok(URL_SAFE_NO_PAD.encode(bytes))
}

fn decode_cursor(value: &str) -> Result<MusicListCursor, AppError> {
let bytes = URL_SAFE_NO_PAD
.decode(value)
.map_err(|_| AppError::bad_request("cursor is invalid"))?;
let payload: CursorPayload =
serde_json::from_slice(&bytes).map_err(|_| AppError::bad_request("cursor is invalid"))?;
let registration_date = DateTime::parse_from_rfc3339(&payload.registration_date)
.map_err(|_| AppError::bad_request("cursor is invalid"))?
.with_timezone(&Utc);
if uuid::Uuid::parse_str(&payload.id).is_err() {
return Err(AppError::bad_request("cursor is invalid"));
}
Ok(MusicListCursor {
registration_date,
id: payload.id,
})
}

pub async fn handle_db_synchronization(
State(state): State<crate::state::State>,
) -> Result<Json<DbSynchronizationResponse>, AppError> {
Expand All @@ -24,3 +103,29 @@ pub async fn handle_db_synchronization(
updated_ratings: result.updated_ratings,
}))
}

#[cfg(test)]
mod tests {
use chrono::{TimeZone, Utc};

use super::*;

#[test]
fn cursor_round_trip_preserves_ordering_key() {
let cursor = MusicListCursor {
registration_date: Utc.with_ymd_and_hms(2025, 10, 1, 12, 0, 0).unwrap(),
id: "00000000-0000-0000-0000-000000000001".to_owned(),
};

let encoded = encode_cursor(cursor.clone()).unwrap();
assert_eq!(decode_cursor(&encoded).unwrap(), cursor);
}

#[test]
fn invalid_cursor_is_rejected() {
assert_eq!(
decode_cursor("not-a-cursor").unwrap_err().status_code,
axum::http::StatusCode::BAD_REQUEST
);
}
}
5 changes: 3 additions & 2 deletions crates/presentation/src/route/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ pub fn create_app(state: State, authenticator: Option<Authenticator>) -> Router
.route("/rating", get(ranking::handle_get_rating_ranking))
.route("/xp", get(ranking::handle_get_xp_ranking));
let health = Router::new().route("/", get(|| async { "OK" }));
let admin_routes =
Router::new().route("/db/synchronize", post(admin::handle_db_synchronization));
let admin_routes = Router::new()
.route("/musics", get(admin::handle_list_musics))
.route("/db/synchronize", post(admin::handle_db_synchronization));

let private_routes = Router::new()
.nest("/users", users)
Expand Down
23 changes: 22 additions & 1 deletion crates/usecase/src/music/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use std::sync::Arc;

use domain::repository::{
Repositories,
music::{MusicRepository, MusicRepositoryError, MusicWithSheets},
music::{MusicListCursor, MusicRepository, MusicRepositoryError, MusicWithSheets},
};
use thiserror::Error;

Expand All @@ -27,6 +27,27 @@ impl<R: Repositories> MusicUsecase<R> {
let musics = self.repositories.music().list_with_sheets().await?;
Ok(musics.into_iter().map(MusicWithSheetsDto::from).collect())
}

pub async fn list_page(
&self,
cursor: Option<MusicListCursor>,
limit: u64,
) -> Result<MusicPageDto, MusicUsecaseError> {
let page = self
.repositories
.music()
.list_with_sheets_page(cursor, limit)
.await?;
Ok(MusicPageDto {
items: page.items.into_iter().map(Into::into).collect(),
next_cursor: page.next_cursor,
})
}
}

pub struct MusicPageDto {
pub items: Vec<MusicWithSheetsDto>,
pub next_cursor: Option<MusicListCursor>,
}

impl<R: Repositories> Clone for MusicUsecase<R> {
Expand Down
Loading
Loading