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:
parent
77cb61f91b
commit
bc449e520e
6 changed files with 306 additions and 58 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue