feat: upgrade update handlers to FlexiblePayload with three-way response

All five existing update handlers (roaster, roast, cafe, gear, bag) now
accept FlexiblePayload with UpdateSubmission types that separate image
data from domain structs. Each returns Datastar redirect scripts, form
redirects, or JSON depending on request type. Image save support added
to all update paths.
This commit is contained in:
Jon Seager 2026-02-10 18:11:15 +00:00
parent 77cb61f91b
commit bc449e520e
No known key found for this signature in database
6 changed files with 306 additions and 58 deletions

View file

@ -8,6 +8,7 @@ use tracing::info;
use crate::application::auth::AuthenticatedUser; use crate::application::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError}; use crate::application::errors::{ApiError, AppError};
use crate::application::routes::api::images::save_deferred_image;
use crate::application::routes::api::macros::{define_delete_handler, define_enriched_get_handler}; use crate::application::routes::api::macros::{define_delete_handler, define_enriched_get_handler};
use crate::application::routes::support::{ use crate::application::routes::support::{
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, FlexiblePayload, ListQuery, PayloadSource, is_datastar_request,
@ -120,6 +121,41 @@ pub(crate) async fn list_bags(
define_enriched_get_handler!(get_bag, BagId, BagWithRoast, bag_repo, get_with_roast); define_enriched_get_handler!(get_bag, BagId, BagWithRoast, bag_repo, get_with_roast);
#[derive(Debug, Deserialize)]
pub(crate) struct UpdateBagSubmission {
#[serde(default)]
roast_id: Option<RoastId>,
#[serde(default)]
roast_date: Option<chrono::NaiveDate>,
#[serde(default)]
amount: Option<f64>,
#[serde(default)]
remaining: Option<f64>,
#[serde(default)]
closed: Option<bool>,
#[serde(default)]
finished_at: Option<chrono::NaiveDate>,
#[serde(default)]
created_at: Option<DateTime<Utc>>,
#[serde(default)]
image: Option<String>,
}
impl UpdateBagSubmission {
fn into_parts(self) -> (UpdateBag, Option<String>) {
let update = UpdateBag {
roast_id: self.roast_id,
roast_date: self.roast_date,
amount: self.amount,
remaining: self.remaining,
closed: self.closed,
finished_at: self.finished_at,
created_at: self.created_at,
};
(update, self.image)
}
}
#[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(
State(state): State<AppState>, State(state): State<AppState>,
@ -128,11 +164,11 @@ pub(crate) async fn update_bag(
Path(id): Path<BagId>, Path(id): Path<BagId>,
Query(query): Query<ListQuery>, Query(query): Query<ListQuery>,
Query(update_params): Query<UpdateBag>, Query(update_params): Query<UpdateBag>,
payload: Option<Json<UpdateBag>>, payload: FlexiblePayload<UpdateBagSubmission>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let (request, search) = query.into_request_and_search::<BagSortKey>(); let (request, search) = query.into_request_and_search::<BagSortKey>();
let (submission, source) = payload.into_parts();
let body_update = payload.map_or(UpdateBag::default(), |Json(p)| p); let (body_update, image_data_url) = submission.into_parts();
let update = UpdateBag { let update = UpdateBag {
roast_id: body_update.roast_id.or(update_params.roast_id), roast_id: body_update.roast_id.or(update_params.roast_id),
@ -161,6 +197,8 @@ pub(crate) async fn update_bag(
info!(%id, closed = ?update.closed, "bag updated"); info!(%id, closed = ?update.closed, "bag updated");
state.stats_invalidator.invalidate(); state.stats_invalidator.invalidate();
save_deferred_image(&state, "bag", i64::from(bag.id), image_data_url.as_deref()).await;
if is_datastar_request(&headers) { if is_datastar_request(&headers) {
let from_bag_page = headers let from_bag_page = headers
.get("referer") .get("referer")
@ -176,6 +214,9 @@ pub(crate) async fn update_bag(
crate::application::routes::support::render_redirect_script(&detail_url) crate::application::routes::support::render_redirect_script(&detail_url)
.map_err(ApiError::from) .map_err(ApiError::from)
} }
} else if matches!(source, PayloadSource::Form) {
let detail_url = format!("/bags/{id}");
Ok(Redirect::to(&detail_url).into_response())
} else { } else {
let enriched = state let enriched = state
.bag_repo .bag_repo

View file

@ -140,20 +140,60 @@ pub(crate) async fn create_cafe(
define_get_handler!(get_cafe, CafeId, Cafe, cafe_repo); define_get_handler!(get_cafe, CafeId, Cafe, cafe_repo);
#[tracing::instrument(skip(state, _auth_user))] #[derive(Debug, Deserialize)]
pub(crate) struct UpdateCafeSubmission {
#[serde(default)]
name: Option<String>,
#[serde(default)]
city: Option<String>,
#[serde(default)]
country: Option<String>,
#[serde(default)]
latitude: Option<f64>,
#[serde(default)]
longitude: Option<f64>,
#[serde(default)]
website: Option<String>,
#[serde(default)]
created_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
image: Option<String>,
}
impl UpdateCafeSubmission {
fn into_parts(self) -> (UpdateCafe, Option<String>) {
let update = UpdateCafe {
name: self.name,
city: self.city,
country: self.country,
latitude: self.latitude,
longitude: self.longitude,
website: self.website,
created_at: self.created_at,
};
(update, self.image)
}
}
#[tracing::instrument(skip(state, _auth_user, headers))]
pub(crate) async fn update_cafe( pub(crate) async fn update_cafe(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
headers: HeaderMap,
Path(id): Path<CafeId>, Path(id): Path<CafeId>,
Json(payload): Json<UpdateCafe>, payload: FlexiblePayload<UpdateCafeSubmission>,
) -> Result<Json<Cafe>, ApiError> { ) -> Result<Response, ApiError> {
let has_changes = payload.name.is_some() let (submission, source) = payload.into_parts();
|| payload.city.is_some() let (update, image_data_url) = submission.into_parts();
|| payload.country.is_some()
|| payload.latitude.is_some() let has_changes = update.name.is_some()
|| payload.longitude.is_some() || update.city.is_some()
|| payload.website.is_some() || update.country.is_some()
|| payload.created_at.is_some(); || update.latitude.is_some()
|| update.longitude.is_some()
|| update.website.is_some()
|| update.created_at.is_some()
|| image_data_url.is_some();
if !has_changes { if !has_changes {
return Err(AppError::validation("no changes provided").into()); return Err(AppError::validation("no changes provided").into());
@ -161,12 +201,29 @@ pub(crate) async fn update_cafe(
let cafe = state let cafe = state
.cafe_repo .cafe_repo
.update(id, payload) .update(id, update)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
info!(%id, "cafe updated"); info!(%id, "cafe updated");
state.stats_invalidator.invalidate(); state.stats_invalidator.invalidate();
Ok(Json(cafe))
save_deferred_image(
&state,
"cafe",
i64::from(cafe.id),
image_data_url.as_deref(),
)
.await;
let detail_url = format!("/cafes/{}", cafe.slug);
if is_datastar_request(&headers) {
render_redirect_script(&detail_url).map_err(ApiError::from)
} else if matches!(source, PayloadSource::Form) {
Ok(Redirect::to(&detail_url).into_response())
} else {
Ok(Json(cafe).into_response())
}
} }
define_delete_handler!( define_delete_handler!(

View file

@ -125,30 +125,72 @@ pub(crate) async fn list_gear(
define_get_handler!(get_gear, GearId, Gear, gear_repo); define_get_handler!(get_gear, GearId, Gear, gear_repo);
#[tracing::instrument(skip(state, _auth_user, headers, query))] #[derive(Debug, Deserialize)]
pub(crate) struct UpdateGearSubmission {
#[serde(default)]
make: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
created_at: Option<DateTime<Utc>>,
#[serde(default)]
image: Option<String>,
}
impl UpdateGearSubmission {
fn into_parts(self) -> (UpdateGear, Option<String>) {
let update = UpdateGear {
make: self.make,
model: self.model,
created_at: self.created_at,
};
(update, self.image)
}
}
#[tracing::instrument(skip(state, _auth_user, headers))]
pub(crate) async fn update_gear( pub(crate) async fn update_gear(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<GearId>, Path(id): Path<GearId>,
Query(query): Query<ListQuery>, payload: FlexiblePayload<UpdateGearSubmission>,
payload: Json<UpdateGear>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let (request, search) = query.into_request_and_search::<GearSortKey>(); let (submission, source) = payload.into_parts();
let (update, image_data_url) = submission.into_parts();
let has_changes = update.make.is_some()
|| update.model.is_some()
|| update.created_at.is_some()
|| image_data_url.is_some();
if !has_changes {
return Err(AppError::validation("no changes provided").into());
}
let gear = state let gear = state
.gear_repo .gear_repo
.update(id, payload.0) .update(id, update)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
info!(%id, "gear updated"); info!(%id, "gear updated");
state.stats_invalidator.invalidate(); state.stats_invalidator.invalidate();
save_deferred_image(
&state,
"gear",
i64::from(gear.id),
image_data_url.as_deref(),
)
.await;
let detail_url = format!("/gear/{}", gear.id);
if is_datastar_request(&headers) { if is_datastar_request(&headers) {
render_gear_list_fragment(state, request, search, true) render_redirect_script(&detail_url).map_err(ApiError::from)
.await } else if matches!(source, PayloadSource::Form) {
.map_err(ApiError::from) Ok(Redirect::to(&detail_url).into_response())
} else { } else {
Ok(Json(gear).into_response()) Ok(Json(gear).into_response())
} }

View file

@ -139,19 +139,53 @@ pub(crate) async fn create_roaster(
define_get_handler!(get_roaster, RoasterId, Roaster, roaster_repo); define_get_handler!(get_roaster, RoasterId, Roaster, roaster_repo);
#[tracing::instrument(skip(state, _auth_user))] #[derive(Debug, Deserialize)]
pub(crate) struct UpdateRoasterSubmission {
#[serde(default)]
name: Option<String>,
#[serde(default)]
country: Option<String>,
#[serde(default)]
city: Option<String>,
#[serde(default)]
homepage: Option<String>,
#[serde(default)]
created_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
image: Option<String>,
}
impl UpdateRoasterSubmission {
fn into_parts(self) -> (UpdateRoaster, Option<String>) {
let update = UpdateRoaster {
name: self.name,
country: self.country,
city: self.city,
homepage: self.homepage,
created_at: self.created_at,
};
(update, self.image)
}
}
#[tracing::instrument(skip(state, _auth_user, headers))]
pub(crate) async fn update_roaster( pub(crate) async fn update_roaster(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
headers: HeaderMap,
Path(id): Path<RoasterId>, Path(id): Path<RoasterId>,
Json(payload): Json<UpdateRoaster>, payload: FlexiblePayload<UpdateRoasterSubmission>,
) -> Result<Json<Roaster>, ApiError> { ) -> Result<Response, ApiError> {
let payload = payload.normalize(); let (submission, source) = payload.into_parts();
let has_changes = payload.name.is_some() let (update, image_data_url) = submission.into_parts();
|| payload.country.is_some() let update = update.normalize();
|| payload.city.is_some()
|| payload.homepage.is_some() let has_changes = update.name.is_some()
|| payload.created_at.is_some(); || update.country.is_some()
|| update.city.is_some()
|| update.homepage.is_some()
|| update.created_at.is_some()
|| image_data_url.is_some();
if !has_changes { if !has_changes {
return Err(AppError::validation("no changes provided").into()); return Err(AppError::validation("no changes provided").into());
@ -159,12 +193,29 @@ pub(crate) async fn update_roaster(
let roaster = state let roaster = state
.roaster_repo .roaster_repo
.update(id, payload) .update(id, update)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
info!(%id, "roaster updated"); info!(%id, "roaster updated");
state.stats_invalidator.invalidate(); state.stats_invalidator.invalidate();
Ok(Json(roaster))
save_deferred_image(
&state,
"roaster",
i64::from(roaster.id),
image_data_url.as_deref(),
)
.await;
let detail_url = format!("/roasters/{}", roaster.slug);
if is_datastar_request(&headers) {
render_redirect_script(&detail_url).map_err(ApiError::from)
} else if matches!(source, PayloadSource::Form) {
Ok(Redirect::to(&detail_url).into_response())
} else {
Ok(Json(roaster).into_response())
}
} }
define_delete_handler!( define_delete_handler!(

View file

@ -176,21 +176,64 @@ define_delete_handler!(
image_type: "roast" image_type: "roast"
); );
#[tracing::instrument(skip(state, _auth_user))] #[derive(Debug, Deserialize)]
pub(crate) struct UpdateRoastSubmission {
#[serde(default)]
roaster_id: Option<RoasterId>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
origin: Option<String>,
#[serde(default)]
region: Option<String>,
#[serde(default)]
producer: Option<String>,
#[serde(default)]
tasting_notes: Option<TastingNotesInput>,
#[serde(default)]
process: Option<String>,
#[serde(default)]
created_at: Option<DateTime<Utc>>,
#[serde(default)]
image: Option<String>,
}
impl UpdateRoastSubmission {
fn into_parts(self) -> (UpdateRoast, Option<String>) {
let update = UpdateRoast {
roaster_id: self.roaster_id,
name: self.name,
origin: self.origin,
region: self.region,
producer: self.producer,
tasting_notes: self.tasting_notes.map(TastingNotesInput::into_vec),
process: self.process,
created_at: self.created_at,
};
(update, self.image)
}
}
#[tracing::instrument(skip(state, _auth_user, headers))]
pub(crate) async fn update_roast( pub(crate) async fn update_roast(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
headers: HeaderMap,
Path(id): Path<RoastId>, Path(id): Path<RoastId>,
Json(payload): Json<UpdateRoast>, payload: FlexiblePayload<UpdateRoastSubmission>,
) -> Result<Json<RoastWithRoaster>, ApiError> { ) -> Result<Response, ApiError> {
let has_changes = payload.roaster_id.is_some() let (submission, source) = payload.into_parts();
|| payload.name.is_some() let (update, image_data_url) = submission.into_parts();
|| payload.origin.is_some()
|| payload.region.is_some() let has_changes = update.roaster_id.is_some()
|| payload.producer.is_some() || update.name.is_some()
|| payload.tasting_notes.is_some() || update.origin.is_some()
|| payload.process.is_some() || update.region.is_some()
|| payload.created_at.is_some(); || update.producer.is_some()
|| update.tasting_notes.is_some()
|| update.process.is_some()
|| update.created_at.is_some()
|| image_data_url.is_some();
if !has_changes { if !has_changes {
return Err(AppError::validation("no changes provided").into()); return Err(AppError::validation("no changes provided").into());
@ -198,20 +241,33 @@ pub(crate) async fn update_roast(
state state
.roast_repo .roast_repo
.update(id, payload) .update(id, update)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
info!(%id, "roast updated"); info!(%id, "roast updated");
state.stats_invalidator.invalidate(); state.stats_invalidator.invalidate();
save_deferred_image(&state, "roast", i64::from(id), image_data_url.as_deref()).await;
let enriched = state let enriched = state
.roast_repo .roast_repo
.get_with_roaster(id) .get_with_roaster(id)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
Ok(Json(enriched)) let detail_url = format!(
"/roasters/{}/roasts/{}",
enriched.roaster_slug, enriched.roast.slug
);
if is_datastar_request(&headers) {
render_redirect_script(&detail_url).map_err(ApiError::from)
} else if matches!(source, PayloadSource::Form) {
Ok(Redirect::to(&detail_url).into_response())
} else {
Ok(Json(enriched).into_response())
}
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]

View file

@ -4,9 +4,9 @@
//! Datastar headers when the `datastar-request: true` header is present. //! Datastar headers when the `datastar-request: true` header is present.
use crate::helpers::{ use crate::helpers::{
TestApp, assert_datastar_headers, assert_full_page, assert_html_fragment, create_default_bag, TestApp, assert_datastar_headers, assert_datastar_headers_with_mode, assert_full_page,
create_default_cafe, create_default_gear, create_default_roast, create_default_roaster, assert_html_fragment, create_default_bag, create_default_cafe, create_default_gear,
spawn_app_with_auth, create_default_roast, create_default_roaster, spawn_app_with_auth,
}; };
use crate::test_macros::define_datastar_entity_tests; use crate::test_macros::define_datastar_entity_tests;
use brewlog::domain::bags::UpdateBag; use brewlog::domain::bags::UpdateBag;
@ -459,7 +459,7 @@ async fn gear_create_without_datastar_header_returns_json() {
} }
#[tokio::test] #[tokio::test]
async fn gear_update_with_datastar_header_returns_fragment() { async fn gear_update_with_datastar_header_returns_redirect_script() {
let app = spawn_app_with_auth().await; let app = spawn_app_with_auth().await;
let client = Client::new(); let client = Client::new();
@ -479,11 +479,9 @@ async fn gear_update_with_datastar_header_returns_fragment() {
let gear: brewlog::domain::gear::Gear = create_response.json().await.unwrap(); let gear: brewlog::domain::gear::Gear = create_response.json().await.unwrap();
let update = brewlog::domain::gear::UpdateGear { let update = serde_json::json!({
make: Some("Updated Make".to_string()), "make": "Updated Make",
model: None, });
created_at: None,
};
let response = client let response = client
.put(app.api_url(&format!("/gear/{}", gear.id))) .put(app.api_url(&format!("/gear/{}", gear.id)))
@ -495,10 +493,13 @@ async fn gear_update_with_datastar_header_returns_fragment() {
.expect("failed to update gear"); .expect("failed to update gear");
assert_eq!(response.status(), 200); assert_eq!(response.status(), 200);
assert_datastar_headers(&response, "#gear-list"); assert_datastar_headers_with_mode(&response, "body", "append");
let body = response.text().await.expect("failed to read body"); let body = response.text().await.expect("failed to read body");
assert_html_fragment(&body); assert!(
body.contains("window.location"),
"Expected redirect script in body"
);
} }
#[tokio::test] #[tokio::test]