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:
parent
bee94d2a2e
commit
b3c13faacf
8 changed files with 100 additions and 17 deletions
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
/// # 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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ pub trait RoasterRepository: Send + Sync {
|
|||
pub trait RoastRepository: Send + Sync {
|
||||
async fn insert(&self, roast: NewRoast) -> 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(
|
||||
&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<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(
|
||||
&self,
|
||||
filter: BagFilter,
|
||||
|
|
|
|||
|
|
@ -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<NaiveDate>,
|
||||
amount: f64,
|
||||
) -> Result<Bag> {
|
||||
) -> Result<BagWithRoast> {
|
||||
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<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")?;
|
||||
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<Bag> {
|
||||
pub async fn get(&self, id: BagId) -> Result<BagWithRoast> {
|
||||
let url = self.inner.endpoint(&format!("api/v1/bags/{id}"))?;
|
||||
let response = self
|
||||
.inner
|
||||
|
|
@ -74,7 +74,7 @@ impl<'a> BagsClient<'a> {
|
|||
remaining: Option<f64>,
|
||||
closed: Option<bool>,
|
||||
finished_at: Option<NaiveDate>,
|
||||
) -> Result<Bag> {
|
||||
) -> Result<BagWithRoast> {
|
||||
let url = self.inner.endpoint(&format!("api/v1/bags/{id}"))?;
|
||||
let payload = UpdateBag {
|
||||
remaining,
|
||||
|
|
|
|||
|
|
@ -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<Roast> {
|
||||
pub async fn create(&self, payload: &NewRoast) -> Result<RoastWithRoaster> {
|
||||
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<Roast> {
|
||||
pub async fn get(&self, id: RoastId) -> Result<RoastWithRoaster> {
|
||||
let url = self.inner.endpoint(&format!("api/v1/roasts/{id}"))?;
|
||||
let response = self
|
||||
.inner
|
||||
|
|
|
|||
|
|
@ -143,6 +143,19 @@ impl BagRepository for SqlBagRepository {
|
|||
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(
|
||||
&self,
|
||||
filter: BagFilter,
|
||||
|
|
|
|||
|
|
@ -203,6 +203,22 @@ impl RoastRepository for SqlRoastRepository {
|
|||
.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(
|
||||
&self,
|
||||
roaster_id: RoasterId,
|
||||
|
|
|
|||
Loading…
Reference in a new issue