fix(api): make all roast and bag endpoints return enriched types

Previously, list endpoints returned enriched types (RoastWithRoaster,
BagWithRoast) with related entity names, while get/create/update
endpoints returned bare types without this information.

This change makes all endpoints consistent by returning enriched types:
- Added get_with_roaster and get_with_roast repository methods
- Created define_enriched_get_handler! macro for custom getter methods
- Updated create and update handlers to fetch enriched data after write
- Updated CLI client to expect enriched types
This commit is contained in:
Jon Seager 2026-02-02 16:43:31 +00:00
parent bee94d2a2e
commit b3c13faacf
No known key found for this signature in database
8 changed files with 100 additions and 17 deletions

View file

@ -4,7 +4,7 @@ use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Redirect, Response}; use axum::response::{IntoResponse, Redirect, Response};
use serde::Deserialize; 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::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError, map_app_error}; use crate::application::errors::{ApiError, AppError, map_app_error};
use crate::application::routes::render_html; use crate::application::routes::render_html;
@ -12,7 +12,7 @@ use crate::application::routes::support::{
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, FlexiblePayload, ListQuery, PayloadSource, is_datastar_request,
}; };
use crate::application::server::AppState; 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::ids::{BagId, RoastId};
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
use crate::domain::roasters::RoasterSortKey; 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); let target = ListNavigator::new(BAG_PAGE_PATH, BAG_FRAGMENT_PATH, request).page_href(1);
Ok(Redirect::to(&target).into_response()) Ok(Redirect::to(&target).into_response())
} else { } 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)) 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))] #[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn update_bag( pub(crate) async fn update_bag(
@ -264,7 +269,12 @@ pub(crate) async fn update_bag(
.await .await
.map_err(ApiError::from) .map_err(ApiError::from)
} else { } 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())
} }
} }

View file

@ -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<crate::application::server::AppState>,
axum::extract::Path(id): axum::extract::Path<$id_type>,
) -> Result<axum::Json<$entity_type>, 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. /// Generates a DELETE handler with Datastar fragment re-rendering support.
/// ///
/// # Arguments /// # Arguments
@ -77,4 +107,5 @@ macro_rules! define_delete_handler {
} }
pub(super) use define_delete_handler; pub(super) use define_delete_handler;
pub(super) use define_enriched_get_handler;
pub(super) use define_get_handler; pub(super) use define_get_handler;

View file

@ -4,7 +4,7 @@ use axum::http::{HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse, Redirect, Response}; use axum::response::{Html, IntoResponse, Redirect, Response};
use serde::Deserialize; 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::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError, map_app_error}; use crate::application::errors::{ApiError, AppError, map_app_error};
use crate::application::routes::render_html; 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::ids::{RoastId, RoasterId};
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
use crate::domain::roasters::RoasterSortKey; use crate::domain::roasters::RoasterSortKey;
use crate::domain::roasts::{NewRoast, Roast, RoastSortKey}; use crate::domain::roasts::{NewRoast, RoastSortKey, RoastWithRoaster};
use crate::presentation::web::templates::{ use crate::presentation::web::templates::{
RoastDetailTemplate, RoastListTemplate, RoastOptionsTemplate, RoastsTemplate, 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); let target = ListNavigator::new(ROAST_PAGE_PATH, ROAST_FRAGMENT_PATH, request).page_href(1);
Ok(Redirect::to(&target).into_response()) Ok(Redirect::to(&target).into_response())
} else { } 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!( define_delete_handler!(
delete_roast, delete_roast,

View file

@ -53,6 +53,7 @@ pub trait RoasterRepository: Send + Sync {
pub trait RoastRepository: Send + Sync { pub trait RoastRepository: Send + Sync {
async fn insert(&self, roast: NewRoast) -> Result<Roast, RepositoryError>; async fn insert(&self, roast: NewRoast) -> Result<Roast, RepositoryError>;
async fn get(&self, id: RoastId) -> Result<Roast, RepositoryError>; async fn get(&self, id: RoastId) -> Result<Roast, RepositoryError>;
async fn get_with_roaster(&self, id: RoastId) -> Result<RoastWithRoaster, RepositoryError>;
async fn get_by_slug( async fn get_by_slug(
&self, &self,
roaster_id: RoasterId, roaster_id: RoasterId,
@ -125,6 +126,7 @@ pub trait SessionRepository: Send + Sync {
pub trait BagRepository: Send + Sync { pub trait BagRepository: Send + Sync {
async fn insert(&self, bag: NewBag) -> Result<Bag, RepositoryError>; async fn insert(&self, bag: NewBag) -> Result<Bag, RepositoryError>;
async fn get(&self, id: BagId) -> Result<Bag, RepositoryError>; async fn get(&self, id: BagId) -> Result<Bag, RepositoryError>;
async fn get_with_roast(&self, id: BagId) -> Result<BagWithRoast, RepositoryError>;
async fn list( async fn list(
&self, &self,
filter: BagFilter, filter: BagFilter,

View file

@ -1,7 +1,7 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use chrono::NaiveDate; use chrono::NaiveDate;
use crate::domain::bags::{Bag, UpdateBag}; use crate::domain::bags::{BagWithRoast, UpdateBag};
use crate::domain::ids::{BagId, RoastId}; use crate::domain::ids::{BagId, RoastId};
use super::BrewlogClient; use super::BrewlogClient;
@ -20,7 +20,7 @@ impl<'a> BagsClient<'a> {
roast_id: RoastId, roast_id: RoastId,
roast_date: Option<NaiveDate>, roast_date: Option<NaiveDate>,
amount: f64, amount: f64,
) -> Result<Bag> { ) -> Result<BagWithRoast> {
let url = self.inner.endpoint("api/v1/bags")?; let url = self.inner.endpoint("api/v1/bags")?;
let payload = serde_json::json!({ let payload = serde_json::json!({
"roast_id": roast_id, "roast_id": roast_id,
@ -39,7 +39,7 @@ impl<'a> BagsClient<'a> {
self.inner.handle_response(response).await self.inner.handle_response(response).await
} }
pub async fn list(&self, roast_id: Option<RoastId>) -> Result<Vec<Bag>> { pub async fn list(&self, roast_id: Option<RoastId>) -> Result<Vec<BagWithRoast>> {
let mut url = self.inner.endpoint("api/v1/bags")?; let mut url = self.inner.endpoint("api/v1/bags")?;
if let Some(roast_id) = roast_id { if let Some(roast_id) = roast_id {
url.query_pairs_mut() url.query_pairs_mut()
@ -56,7 +56,7 @@ impl<'a> BagsClient<'a> {
self.inner.handle_response(response).await self.inner.handle_response(response).await
} }
pub async fn get(&self, id: BagId) -> Result<Bag> { pub async fn get(&self, id: BagId) -> Result<BagWithRoast> {
let url = self.inner.endpoint(&format!("api/v1/bags/{id}"))?; let url = self.inner.endpoint(&format!("api/v1/bags/{id}"))?;
let response = self let response = self
.inner .inner
@ -74,7 +74,7 @@ impl<'a> BagsClient<'a> {
remaining: Option<f64>, remaining: Option<f64>,
closed: Option<bool>, closed: Option<bool>,
finished_at: Option<NaiveDate>, finished_at: Option<NaiveDate>,
) -> Result<Bag> { ) -> Result<BagWithRoast> {
let url = self.inner.endpoint(&format!("api/v1/bags/{id}"))?; let url = self.inner.endpoint(&format!("api/v1/bags/{id}"))?;
let payload = UpdateBag { let payload = UpdateBag {
remaining, remaining,

View file

@ -2,7 +2,7 @@ use anyhow::{Context, Result};
use reqwest::StatusCode; use reqwest::StatusCode;
use crate::domain::ids::{RoastId, RoasterId}; use crate::domain::ids::{RoastId, RoasterId};
use crate::domain::roasts::{NewRoast, Roast, RoastWithRoaster}; use crate::domain::roasts::{NewRoast, RoastWithRoaster};
use super::BrewlogClient; use super::BrewlogClient;
@ -15,7 +15,7 @@ impl<'a> RoastsClient<'a> {
Self { inner } Self { inner }
} }
pub async fn create(&self, payload: &NewRoast) -> Result<Roast> { pub async fn create(&self, payload: &NewRoast) -> Result<RoastWithRoaster> {
let url = self.inner.endpoint("api/v1/roasts")?; let url = self.inner.endpoint("api/v1/roasts")?;
let response = self let response = self
.inner .inner
@ -45,7 +45,7 @@ impl<'a> RoastsClient<'a> {
self.inner.handle_response(response).await self.inner.handle_response(response).await
} }
pub async fn get(&self, id: RoastId) -> Result<Roast> { pub async fn get(&self, id: RoastId) -> Result<RoastWithRoaster> {
let url = self.inner.endpoint(&format!("api/v1/roasts/{id}"))?; let url = self.inner.endpoint(&format!("api/v1/roasts/{id}"))?;
let response = self let response = self
.inner .inner

View file

@ -143,6 +143,19 @@ impl BagRepository for SqlBagRepository {
Ok(Self::to_domain(record)) Ok(Self::to_domain(record))
} }
async fn get_with_roast(&self, id: BagId) -> Result<BagWithRoast, RepositoryError> {
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( async fn list(
&self, &self,
filter: BagFilter, filter: BagFilter,

View file

@ -203,6 +203,22 @@ impl RoastRepository for SqlRoastRepository {
.ok_or(RepositoryError::NotFound) .ok_or(RepositoryError::NotFound)
} }
async fn get_with_roaster(&self, id: RoastId) -> Result<RoastWithRoaster, RepositoryError> {
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( async fn get_by_slug(
&self, &self,
roaster_id: RoasterId, roaster_id: RoasterId,