refactor(routes): add macros to reduce route handler boilerplate

- Create define_get_handler! macro for GET-by-ID endpoints
- Create define_delete_handler! macro for DELETE endpoints with Datastar support
- Apply macros to roasters, roasts, and bags route modules
- Reduces 6 handlers from ~78 lines to ~12 lines total
This commit is contained in:
Jon Seager 2026-02-02 13:40:14 +00:00
parent 9f70aa0892
commit 2d183e6955
No known key found for this signature in database
5 changed files with 108 additions and 85 deletions

View file

@ -4,6 +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 crate::application::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError, map_app_error};
use crate::application::routes::render_html;
@ -184,14 +185,7 @@ pub(crate) async fn list_bags(
Ok(Json(bags))
}
#[tracing::instrument(skip(state))]
pub(crate) async fn get_bag(
State(state): State<AppState>,
Path(id): Path<BagId>,
) -> Result<Json<Bag>, ApiError> {
let bag = state.bag_repo.get(id).await.map_err(AppError::from)?;
Ok(Json(bag))
}
define_get_handler!(get_bag, BagId, Bag, bag_repo);
#[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn update_bag(
@ -265,25 +259,13 @@ pub(crate) async fn update_bag(
}
}
#[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn delete_bag(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Path(id): Path<BagId>,
Query(query): Query<ListQuery>,
) -> Result<Response, ApiError> {
let request = query.into_request::<BagSortKey>();
state.bag_repo.delete(id).await.map_err(AppError::from)?;
if is_datastar_request(&headers) {
render_bag_list_fragment(state, request, true)
.await
.map_err(ApiError::from)
} else {
Ok(StatusCode::NO_CONTENT.into_response())
}
}
define_delete_handler!(
delete_bag,
BagId,
BagSortKey,
bag_repo,
render_bag_list_fragment
);
#[derive(Debug, Deserialize)]
pub struct BagsQuery {

View file

@ -0,0 +1,80 @@
/// Generates a GET-by-ID handler that retrieves an entity from a repository.
///
/// # Arguments
/// * `$fn_name` - Name of the generated handler function
/// * `$id_type` - Type of the ID path parameter (e.g., `RoasterId`)
/// * `$entity_type` - Type of the entity returned as JSON (e.g., `Roaster`)
/// * `$repo_field` - Name of the repository field on `AppState` (e.g., `roaster_repo`)
///
/// # Example
/// ```ignore
/// define_get_handler!(get_roaster, RoasterId, Roaster, roaster_repo);
/// ```
macro_rules! define_get_handler {
($fn_name:ident, $id_type:ty, $entity_type:ty, $repo_field: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
.get(id)
.await
.map_err(crate::application::errors::AppError::from)?;
Ok(axum::Json(entity))
}
};
}
/// Generates a DELETE handler with Datastar fragment re-rendering support.
///
/// # Arguments
/// * `$fn_name` - Name of the generated handler function
/// * `$id_type` - Type of the ID path parameter (e.g., `RoasterId`)
/// * `$sort_key` - Sort key type for list requests (e.g., `RoasterSortKey`)
/// * `$repo_field` - Name of the repository field on `AppState` (e.g., `roaster_repo`)
/// * `$render_fragment` - Path to the fragment render function
///
/// # Example
/// ```ignore
/// define_delete_handler!(
/// delete_roaster,
/// RoasterId,
/// RoasterSortKey,
/// roaster_repo,
/// render_roaster_list_fragment
/// );
/// ```
macro_rules! define_delete_handler {
($fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path) => {
#[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn $fn_name(
axum::extract::State(state): axum::extract::State<crate::application::server::AppState>,
_auth_user: crate::application::auth::AuthenticatedUser,
headers: axum::http::HeaderMap,
axum::extract::Path(id): axum::extract::Path<$id_type>,
axum::extract::Query(query): axum::extract::Query<
crate::application::routes::support::ListQuery,
>,
) -> Result<axum::response::Response, crate::application::errors::ApiError> {
let request = query.into_request::<$sort_key>();
state
.$repo_field
.delete(id)
.await
.map_err(crate::application::errors::AppError::from)?;
if crate::application::routes::support::is_datastar_request(&headers) {
$render_fragment(state, request, true)
.await
.map_err(crate::application::errors::ApiError::from)
} else {
Ok(axum::http::StatusCode::NO_CONTENT.into_response())
}
}
};
}
pub(super) use define_delete_handler;
pub(super) use define_get_handler;

View file

@ -1,5 +1,6 @@
pub mod auth;
pub mod bags;
mod macros;
pub mod roasters;
pub mod roasts;
pub mod support;

View file

@ -3,6 +3,7 @@ use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse, Redirect, Response};
use super::macros::{define_delete_handler, define_get_handler};
use crate::application::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError, map_app_error};
use crate::application::routes::render_html;
@ -148,14 +149,7 @@ pub(crate) async fn create_roaster(
}
}
#[tracing::instrument(skip(state))]
pub(crate) async fn get_roaster(
State(state): State<AppState>,
Path(id): Path<RoasterId>,
) -> Result<Json<Roaster>, ApiError> {
let roaster = state.roaster_repo.get(id).await.map_err(AppError::from)?;
Ok(Json(roaster))
}
define_get_handler!(get_roaster, RoasterId, Roaster, roaster_repo);
#[tracing::instrument(skip(state, _auth_user))]
pub(crate) async fn update_roaster(
@ -182,29 +176,13 @@ pub(crate) async fn update_roaster(
Ok(Json(roaster))
}
#[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn delete_roaster(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Path(id): Path<RoasterId>,
Query(query): Query<ListQuery>,
) -> Result<Response, ApiError> {
let request = query.into_request::<RoasterSortKey>();
state
.roaster_repo
.delete(id)
.await
.map_err(AppError::from)?;
if is_datastar_request(&headers) {
render_roaster_list_fragment(state, request, true)
.await
.map_err(ApiError::from)
} else {
Ok(StatusCode::NO_CONTENT.into_response())
}
}
define_delete_handler!(
delete_roaster,
RoasterId,
RoasterSortKey,
roaster_repo,
render_roaster_list_fragment
);
async fn render_roaster_list_fragment(
state: AppState,

View file

@ -4,6 +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 crate::application::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError, map_app_error};
use crate::application::routes::render_html;
@ -205,34 +206,15 @@ pub(crate) async fn list_roasts(
}
}
#[tracing::instrument(skip(state))]
pub(crate) async fn get_roast(
State(state): State<AppState>,
Path(id): Path<RoastId>,
) -> Result<Json<Roast>, ApiError> {
let roast = state.roast_repo.get(id).await.map_err(AppError::from)?;
Ok(Json(roast))
}
define_get_handler!(get_roast, RoastId, Roast, roast_repo);
#[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn delete_roast(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Path(id): Path<RoastId>,
Query(query): Query<ListQuery>,
) -> Result<Response, ApiError> {
let request = query.into_request::<RoastSortKey>();
state.roast_repo.delete(id).await.map_err(AppError::from)?;
if is_datastar_request(&headers) {
render_roast_list_fragment(state, request, true)
.await
.map_err(ApiError::from)
} else {
Ok(StatusCode::NO_CONTENT.into_response())
}
}
define_delete_handler!(
delete_roast,
RoastId,
RoastSortKey,
roast_repo,
render_roast_list_fragment
);
#[derive(Debug, Deserialize)]
pub struct RoastsQuery {