diff --git a/src/application/routes/bags.rs b/src/application/routes/bags.rs index c711afe..61b488f 100644 --- a/src/application/routes/bags.rs +++ b/src/application/routes/bags.rs @@ -4,7 +4,7 @@ use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Redirect, Response}; use serde::Deserialize; -use super::macros::{define_delete_handler, define_get_handler}; +use super::macros::{define_delete_handler, define_enriched_get_handler}; use crate::application::auth::AuthenticatedUser; use crate::application::errors::{ApiError, AppError, map_app_error}; use crate::application::routes::render_html; @@ -12,7 +12,7 @@ use crate::application::routes::support::{ FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, }; use crate::application::server::AppState; -use crate::domain::bags::{Bag, BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag}; +use crate::domain::bags::{BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag}; use crate::domain::ids::{BagId, RoastId}; use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::roasters::RoasterSortKey; @@ -172,7 +172,12 @@ pub(crate) async fn create_bag( let target = ListNavigator::new(BAG_PAGE_PATH, BAG_FRAGMENT_PATH, request).page_href(1); Ok(Redirect::to(&target).into_response()) } else { - Ok((StatusCode::CREATED, Json(bag)).into_response()) + let enriched = state + .bag_repo + .get_with_roast(bag.id) + .await + .map_err(AppError::from)?; + Ok((StatusCode::CREATED, Json(enriched)).into_response()) } } @@ -194,7 +199,7 @@ pub(crate) async fn list_bags( Ok(Json(page.items)) } -define_get_handler!(get_bag, BagId, Bag, bag_repo); +define_enriched_get_handler!(get_bag, BagId, BagWithRoast, bag_repo, get_with_roast); #[tracing::instrument(skip(state, _auth_user, headers, query))] pub(crate) async fn update_bag( @@ -264,7 +269,12 @@ pub(crate) async fn update_bag( .await .map_err(ApiError::from) } else { - Ok(Json(bag).into_response()) + let enriched = state + .bag_repo + .get_with_roast(bag.id) + .await + .map_err(AppError::from)?; + Ok(Json(enriched).into_response()) } } diff --git a/src/application/routes/macros.rs b/src/application/routes/macros.rs index cd452a9..20674d5 100644 --- a/src/application/routes/macros.rs +++ b/src/application/routes/macros.rs @@ -27,6 +27,36 @@ macro_rules! define_get_handler { }; } +/// Generates a GET-by-ID handler that retrieves an enriched entity using a custom method. +/// +/// # Arguments +/// * `$fn_name` - Name of the generated handler function +/// * `$id_type` - Type of the ID path parameter (e.g., `RoastId`) +/// * `$entity_type` - Type of the enriched entity returned as JSON (e.g., `RoastWithRoaster`) +/// * `$repo_field` - Name of the repository field on `AppState` (e.g., `roast_repo`) +/// * `$method` - Name of the repository method to call (e.g., `get_with_roaster`) +/// +/// # Example +/// ```ignore +/// define_enriched_get_handler!(get_roast, RoastId, RoastWithRoaster, roast_repo, get_with_roaster); +/// ``` +macro_rules! define_enriched_get_handler { + ($fn_name:ident, $id_type:ty, $entity_type:ty, $repo_field:ident, $method:ident) => { + #[tracing::instrument(skip(state))] + pub(crate) async fn $fn_name( + axum::extract::State(state): axum::extract::State, + axum::extract::Path(id): axum::extract::Path<$id_type>, + ) -> Result, crate::application::errors::ApiError> { + let entity = state + .$repo_field + .$method(id) + .await + .map_err(crate::application::errors::AppError::from)?; + Ok(axum::Json(entity)) + } + }; +} + /// Generates a DELETE handler with Datastar fragment re-rendering support. /// /// # Arguments @@ -77,4 +107,5 @@ macro_rules! define_delete_handler { } pub(super) use define_delete_handler; +pub(super) use define_enriched_get_handler; pub(super) use define_get_handler; diff --git a/src/application/routes/roasts.rs b/src/application/routes/roasts.rs index 028c9df..3d1066e 100644 --- a/src/application/routes/roasts.rs +++ b/src/application/routes/roasts.rs @@ -4,7 +4,7 @@ use axum::http::{HeaderMap, StatusCode}; use axum::response::{Html, IntoResponse, Redirect, Response}; use serde::Deserialize; -use super::macros::{define_delete_handler, define_get_handler}; +use super::macros::{define_delete_handler, define_enriched_get_handler}; use crate::application::auth::AuthenticatedUser; use crate::application::errors::{ApiError, AppError, map_app_error}; use crate::application::routes::render_html; @@ -16,7 +16,7 @@ use crate::domain::bags::{BagFilter, BagSortKey}; use crate::domain::ids::{RoastId, RoasterId}; use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::roasters::RoasterSortKey; -use crate::domain::roasts::{NewRoast, Roast, RoastSortKey}; +use crate::domain::roasts::{NewRoast, RoastSortKey, RoastWithRoaster}; use crate::presentation::web::templates::{ RoastDetailTemplate, RoastListTemplate, RoastOptionsTemplate, RoastsTemplate, }; @@ -161,7 +161,12 @@ pub(crate) async fn create_roast( let target = ListNavigator::new(ROAST_PAGE_PATH, ROAST_FRAGMENT_PATH, request).page_href(1); Ok(Redirect::to(&target).into_response()) } else { - Ok((StatusCode::CREATED, Json(roast)).into_response()) + let enriched = state + .roast_repo + .get_with_roaster(roast.id) + .await + .map_err(AppError::from)?; + Ok((StatusCode::CREATED, Json(enriched)).into_response()) } } @@ -206,7 +211,13 @@ pub(crate) async fn list_roasts( } } -define_get_handler!(get_roast, RoastId, Roast, roast_repo); +define_enriched_get_handler!( + get_roast, + RoastId, + RoastWithRoaster, + roast_repo, + get_with_roaster +); define_delete_handler!( delete_roast, diff --git a/src/domain/repositories.rs b/src/domain/repositories.rs index 02ce65b..743a953 100644 --- a/src/domain/repositories.rs +++ b/src/domain/repositories.rs @@ -53,6 +53,7 @@ pub trait RoasterRepository: Send + Sync { pub trait RoastRepository: Send + Sync { async fn insert(&self, roast: NewRoast) -> Result; async fn get(&self, id: RoastId) -> Result; + async fn get_with_roaster(&self, id: RoastId) -> Result; async fn get_by_slug( &self, roaster_id: RoasterId, @@ -125,6 +126,7 @@ pub trait SessionRepository: Send + Sync { pub trait BagRepository: Send + Sync { async fn insert(&self, bag: NewBag) -> Result; async fn get(&self, id: BagId) -> Result; + async fn get_with_roast(&self, id: BagId) -> Result; async fn list( &self, filter: BagFilter, diff --git a/src/infrastructure/client/bags.rs b/src/infrastructure/client/bags.rs index 542a80e..22b6ed7 100644 --- a/src/infrastructure/client/bags.rs +++ b/src/infrastructure/client/bags.rs @@ -1,7 +1,7 @@ use anyhow::{Context, Result}; use chrono::NaiveDate; -use crate::domain::bags::{Bag, UpdateBag}; +use crate::domain::bags::{BagWithRoast, UpdateBag}; use crate::domain::ids::{BagId, RoastId}; use super::BrewlogClient; @@ -20,7 +20,7 @@ impl<'a> BagsClient<'a> { roast_id: RoastId, roast_date: Option, amount: f64, - ) -> Result { + ) -> Result { let url = self.inner.endpoint("api/v1/bags")?; let payload = serde_json::json!({ "roast_id": roast_id, @@ -39,7 +39,7 @@ impl<'a> BagsClient<'a> { self.inner.handle_response(response).await } - pub async fn list(&self, roast_id: Option) -> Result> { + pub async fn list(&self, roast_id: Option) -> Result> { let mut url = self.inner.endpoint("api/v1/bags")?; if let Some(roast_id) = roast_id { url.query_pairs_mut() @@ -56,7 +56,7 @@ impl<'a> BagsClient<'a> { self.inner.handle_response(response).await } - pub async fn get(&self, id: BagId) -> Result { + pub async fn get(&self, id: BagId) -> Result { let url = self.inner.endpoint(&format!("api/v1/bags/{id}"))?; let response = self .inner @@ -74,7 +74,7 @@ impl<'a> BagsClient<'a> { remaining: Option, closed: Option, finished_at: Option, - ) -> Result { + ) -> Result { let url = self.inner.endpoint(&format!("api/v1/bags/{id}"))?; let payload = UpdateBag { remaining, diff --git a/src/infrastructure/client/roasts.rs b/src/infrastructure/client/roasts.rs index 37810b6..b2f0010 100644 --- a/src/infrastructure/client/roasts.rs +++ b/src/infrastructure/client/roasts.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use reqwest::StatusCode; use crate::domain::ids::{RoastId, RoasterId}; -use crate::domain::roasts::{NewRoast, Roast, RoastWithRoaster}; +use crate::domain::roasts::{NewRoast, RoastWithRoaster}; use super::BrewlogClient; @@ -15,7 +15,7 @@ impl<'a> RoastsClient<'a> { Self { inner } } - pub async fn create(&self, payload: &NewRoast) -> Result { + pub async fn create(&self, payload: &NewRoast) -> Result { let url = self.inner.endpoint("api/v1/roasts")?; let response = self .inner @@ -45,7 +45,7 @@ impl<'a> RoastsClient<'a> { self.inner.handle_response(response).await } - pub async fn get(&self, id: RoastId) -> Result { + pub async fn get(&self, id: RoastId) -> Result { let url = self.inner.endpoint(&format!("api/v1/roasts/{id}"))?; let response = self .inner diff --git a/src/infrastructure/repositories/bags.rs b/src/infrastructure/repositories/bags.rs index 82a1842..c9921da 100644 --- a/src/infrastructure/repositories/bags.rs +++ b/src/infrastructure/repositories/bags.rs @@ -143,6 +143,19 @@ impl BagRepository for SqlBagRepository { Ok(Self::to_domain(record)) } + async fn get_with_roast(&self, id: BagId) -> Result { + let query = format!("{} WHERE b.id = ?", BASE_SELECT); + + let record = query_as::<_, BagWithRoastRecord>(&query) + .bind(id.into_inner()) + .fetch_optional(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))? + .ok_or(RepositoryError::NotFound)?; + + Ok(Self::to_domain_with_roast(record)) + } + async fn list( &self, filter: BagFilter, diff --git a/src/infrastructure/repositories/roasts.rs b/src/infrastructure/repositories/roasts.rs index 19e5816..ebc697d 100644 --- a/src/infrastructure/repositories/roasts.rs +++ b/src/infrastructure/repositories/roasts.rs @@ -203,6 +203,22 @@ impl RoastRepository for SqlRoastRepository { .ok_or(RepositoryError::NotFound) } + async fn get_with_roaster(&self, id: RoastId) -> Result { + query_as::<_, RoastWithRoasterRecord>( + "SELECT r.id, r.roaster_id, r.name, r.slug, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name, ro.slug AS roaster_slug \ + FROM roasts r \ + JOIN roasters ro ON ro.id = r.roaster_id \ + WHERE r.id = ?", + ) + .bind(i64::from(id)) + .fetch_optional(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))? + .map(|record| record.into_with_roaster()) + .transpose()? + .ok_or(RepositoryError::NotFound) + } + async fn get_by_slug( &self, roaster_id: RoasterId,