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::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::support::{
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);
#[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))]
pub(crate) async fn update_bag(
State(state): State<AppState>,
@ -128,11 +164,11 @@ pub(crate) async fn update_bag(
Path(id): Path<BagId>,
Query(query): Query<ListQuery>,
Query(update_params): Query<UpdateBag>,
payload: Option<Json<UpdateBag>>,
payload: FlexiblePayload<UpdateBagSubmission>,
) -> Result<Response, ApiError> {
let (request, search) = query.into_request_and_search::<BagSortKey>();
let body_update = payload.map_or(UpdateBag::default(), |Json(p)| p);
let (submission, source) = payload.into_parts();
let (body_update, image_data_url) = submission.into_parts();
let update = UpdateBag {
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");
state.stats_invalidator.invalidate();
save_deferred_image(&state, "bag", i64::from(bag.id), image_data_url.as_deref()).await;
if is_datastar_request(&headers) {
let from_bag_page = headers
.get("referer")
@ -176,6 +214,9 @@ pub(crate) async fn update_bag(
crate::application::routes::support::render_redirect_script(&detail_url)
.map_err(ApiError::from)
}
} else if matches!(source, PayloadSource::Form) {
let detail_url = format!("/bags/{id}");
Ok(Redirect::to(&detail_url).into_response())
} else {
let enriched = state
.bag_repo

View file

@ -140,20 +140,60 @@ pub(crate) async fn create_cafe(
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(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Path(id): Path<CafeId>,
Json(payload): Json<UpdateCafe>,
) -> Result<Json<Cafe>, ApiError> {
let has_changes = payload.name.is_some()
|| payload.city.is_some()
|| payload.country.is_some()
|| payload.latitude.is_some()
|| payload.longitude.is_some()
|| payload.website.is_some()
|| payload.created_at.is_some();
payload: FlexiblePayload<UpdateCafeSubmission>,
) -> Result<Response, ApiError> {
let (submission, source) = payload.into_parts();
let (update, image_data_url) = submission.into_parts();
let has_changes = update.name.is_some()
|| update.city.is_some()
|| update.country.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 {
return Err(AppError::validation("no changes provided").into());
@ -161,12 +201,29 @@ pub(crate) async fn update_cafe(
let cafe = state
.cafe_repo
.update(id, payload)
.update(id, update)
.await
.map_err(AppError::from)?;
info!(%id, "cafe updated");
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!(

View file

@ -125,30 +125,72 @@ pub(crate) async fn list_gear(
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(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Path(id): Path<GearId>,
Query(query): Query<ListQuery>,
payload: Json<UpdateGear>,
payload: FlexiblePayload<UpdateGearSubmission>,
) -> 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
.gear_repo
.update(id, payload.0)
.update(id, update)
.await
.map_err(AppError::from)?;
info!(%id, "gear updated");
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) {
render_gear_list_fragment(state, request, search, true)
.await
.map_err(ApiError::from)
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(gear).into_response())
}

View file

@ -139,19 +139,53 @@ pub(crate) async fn create_roaster(
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(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Path(id): Path<RoasterId>,
Json(payload): Json<UpdateRoaster>,
) -> Result<Json<Roaster>, ApiError> {
let payload = payload.normalize();
let has_changes = payload.name.is_some()
|| payload.country.is_some()
|| payload.city.is_some()
|| payload.homepage.is_some()
|| payload.created_at.is_some();
payload: FlexiblePayload<UpdateRoasterSubmission>,
) -> Result<Response, ApiError> {
let (submission, source) = payload.into_parts();
let (update, image_data_url) = submission.into_parts();
let update = update.normalize();
let has_changes = update.name.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 {
return Err(AppError::validation("no changes provided").into());
@ -159,12 +193,29 @@ pub(crate) async fn update_roaster(
let roaster = state
.roaster_repo
.update(id, payload)
.update(id, update)
.await
.map_err(AppError::from)?;
info!(%id, "roaster updated");
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!(

View file

@ -176,21 +176,64 @@ define_delete_handler!(
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(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Path(id): Path<RoastId>,
Json(payload): Json<UpdateRoast>,
) -> Result<Json<RoastWithRoaster>, ApiError> {
let has_changes = payload.roaster_id.is_some()
|| payload.name.is_some()
|| payload.origin.is_some()
|| payload.region.is_some()
|| payload.producer.is_some()
|| payload.tasting_notes.is_some()
|| payload.process.is_some()
|| payload.created_at.is_some();
payload: FlexiblePayload<UpdateRoastSubmission>,
) -> Result<Response, ApiError> {
let (submission, source) = payload.into_parts();
let (update, image_data_url) = submission.into_parts();
let has_changes = update.roaster_id.is_some()
|| update.name.is_some()
|| update.origin.is_some()
|| update.region.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 {
return Err(AppError::validation("no changes provided").into());
@ -198,20 +241,33 @@ pub(crate) async fn update_roast(
state
.roast_repo
.update(id, payload)
.update(id, update)
.await
.map_err(AppError::from)?;
info!(%id, "roast updated");
state.stats_invalidator.invalidate();
save_deferred_image(&state, "roast", i64::from(id), image_data_url.as_deref()).await;
let enriched = state
.roast_repo
.get_with_roaster(id)
.await
.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)]

View file

@ -4,9 +4,9 @@
//! Datastar headers when the `datastar-request: true` header is present.
use crate::helpers::{
TestApp, assert_datastar_headers, assert_full_page, assert_html_fragment, create_default_bag,
create_default_cafe, create_default_gear, create_default_roast, create_default_roaster,
spawn_app_with_auth,
TestApp, assert_datastar_headers, assert_datastar_headers_with_mode, assert_full_page,
assert_html_fragment, create_default_bag, create_default_cafe, create_default_gear,
create_default_roast, create_default_roaster, spawn_app_with_auth,
};
use crate::test_macros::define_datastar_entity_tests;
use brewlog::domain::bags::UpdateBag;
@ -459,7 +459,7 @@ async fn gear_create_without_datastar_header_returns_json() {
}
#[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 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 update = brewlog::domain::gear::UpdateGear {
make: Some("Updated Make".to_string()),
model: None,
created_at: None,
};
let update = serde_json::json!({
"make": "Updated Make",
});
let response = client
.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");
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");
assert_html_fragment(&body);
assert!(
body.contains("window.location"),
"Expected redirect script in body"
);
}
#[tokio::test]