feat(application): add Gear API routes and web handlers

Implement complete HTTP interface for Gear entity with API endpoints and web views.

Route Handlers (application/routes/gear.rs):
- gear_page(): Web page handler with Datastar fragment support
- create_gear(): Create gear with timeline event logging
- list_gear(): JSON API with optional category filter
- get_gear(): Single gear retrieval (using macro)
- update_gear(): Update gear fields
- delete_gear(): Delete gear (using macro)
- load_gear_page(): Helper for paginated gear data
- render_gear_list_fragment(): Fragment rendering for Datastar updates
- NewGearSubmission: Input validation with category parsing and empty checks

Timeline Integration:
- Logs "added" events with Category, Make, Model details
- Events created in route handler (route-based approach like Bags)

Route Registration (application/routes/mod.rs):
- API routes: GET/POST /api/v1/gear, GET/PUT/DELETE /api/v1/gear/:id
- Web route: GET /gear

AppState Updates (application/server.rs):
- Add gear_repo field to AppState struct
- Initialize SqlGearRepository in serve() function
- Update AppState::new() to include gear_repo parameter

Supports both JSON API and form submissions with Datastar reactive updates.
This commit is contained in:
Jon Seager 2026-02-02 15:21:26 +00:00
parent 2309a696cc
commit 1ef447157e
No known key found for this signature in database
3 changed files with 260 additions and 2 deletions

View file

@ -0,0 +1,243 @@
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Redirect, Response};
use axum::Json;
use serde::Deserialize;
use super::macros::{define_delete_handler, define_get_handler};
use crate::application::auth::AuthenticatedUser;
use crate::application::errors::{map_app_error, ApiError, AppError};
use crate::application::routes::render_html;
use crate::application::routes::support::{
is_datastar_request, FlexiblePayload, ListQuery, PayloadSource,
};
use crate::application::server::AppState;
use crate::domain::gear::{Gear, GearCategory, GearFilter, GearSortKey, NewGear, UpdateGear};
use crate::domain::ids::GearId;
use crate::domain::listing::{ListRequest, SortDirection};
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
use crate::presentation::web::templates::{GearListTemplate, GearTemplate};
use crate::presentation::web::views::{GearView, ListNavigator, Paginated};
const GEAR_PAGE_PATH: &str = "/gear";
const GEAR_FRAGMENT_PATH: &str = "/gear#gear-list";
#[tracing::instrument(skip(state))]
async fn load_gear_page(
state: &AppState,
request: ListRequest<GearSortKey>,
) -> Result<Paginated<GearView>, AppError> {
let page = state
.gear_repo
.list(GearFilter::all(), &request)
.await
.map_err(AppError::from)?;
let (gear, _navigator) = crate::application::routes::support::build_page_view(
page,
request,
GearView::from_domain,
GEAR_PAGE_PATH,
GEAR_FRAGMENT_PATH,
);
Ok(gear)
}
#[tracing::instrument(skip(state, cookies, headers, query))]
pub(crate) async fn gear_page(
State(state): State<AppState>,
cookies: tower_cookies::Cookies,
headers: HeaderMap,
Query(query): Query<ListQuery>,
) -> Result<Response, StatusCode> {
let request = query.into_request::<GearSortKey>();
if is_datastar_request(&headers) {
let is_authenticated = super::is_authenticated(&state, &cookies).await;
return render_gear_list_fragment(state, request, is_authenticated)
.await
.map_err(map_app_error);
}
let gear = load_gear_page(&state, request)
.await
.map_err(map_app_error)?;
let is_authenticated = super::is_authenticated(&state, &cookies).await;
let navigator = ListNavigator::new(GEAR_PAGE_PATH, GEAR_FRAGMENT_PATH, request);
let template = GearTemplate {
nav_active: "gear",
is_authenticated,
gear,
navigator,
};
render_html(template).map(IntoResponse::into_response)
}
#[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn create_gear(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Query(query): Query<ListQuery>,
payload: FlexiblePayload<NewGearSubmission>,
) -> Result<Response, ApiError> {
let request = query.into_request::<GearSortKey>();
let (submission, source) = payload.into_parts();
let new_gear = submission.into_new_gear().map_err(ApiError::from)?;
let gear = state
.gear_repo
.insert(new_gear)
.await
.map_err(AppError::from)?;
// Add timeline event
let event = NewTimelineEvent {
entity_type: "gear".to_string(),
entity_id: gear.id.into_inner(),
action: "added".to_string(),
occurred_at: chrono::Utc::now(),
title: format!("{} {}", gear.make, gear.model),
details: vec![
TimelineEventDetail {
label: "Category".to_string(),
value: gear.category.display_label().to_string(),
},
TimelineEventDetail {
label: "Make".to_string(),
value: gear.make.clone(),
},
TimelineEventDetail {
label: "Model".to_string(),
value: gear.model.clone(),
},
],
tasting_notes: vec![],
};
let _ = state.timeline_repo.insert(event).await;
if is_datastar_request(&headers) {
render_gear_list_fragment(state, request, true)
.await
.map_err(ApiError::from)
} else if matches!(source, PayloadSource::Form) {
let target = ListNavigator::new(GEAR_PAGE_PATH, GEAR_FRAGMENT_PATH, request).page_href(1);
Ok(Redirect::to(&target).into_response())
} else {
Ok((StatusCode::CREATED, Json(gear)).into_response())
}
}
#[tracing::instrument(skip(state))]
pub(crate) async fn list_gear(
State(state): State<AppState>,
Query(params): Query<GearQuery>,
) -> Result<Json<Vec<Gear>>, ApiError> {
let filter = match params.category {
Some(ref cat_str) => {
let category = GearCategory::from_str(cat_str)
.ok_or_else(|| AppError::validation("invalid category"))?;
GearFilter::for_category(category)
}
None => GearFilter::all(),
};
let request = ListRequest::show_all(GearSortKey::Make, SortDirection::Asc);
let page = state
.gear_repo
.list(filter, &request)
.await
.map_err(AppError::from)?;
Ok(Json(page.items))
}
define_get_handler!(get_gear, GearId, Gear, gear_repo);
#[tracing::instrument(skip(state, _auth_user, headers, query))]
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>,
) -> Result<Response, ApiError> {
let request = query.into_request::<GearSortKey>();
let gear = state
.gear_repo
.update(id, payload.0)
.await
.map_err(AppError::from)?;
if is_datastar_request(&headers) {
render_gear_list_fragment(state, request, true)
.await
.map_err(ApiError::from)
} else {
Ok(Json(gear).into_response())
}
}
define_delete_handler!(
delete_gear,
GearId,
GearSortKey,
gear_repo,
render_gear_list_fragment
);
#[derive(Debug, Deserialize)]
pub struct GearQuery {
pub category: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct NewGearSubmission {
category: String,
make: String,
model: String,
notes: Option<String>,
}
impl NewGearSubmission {
fn into_new_gear(self) -> Result<NewGear, AppError> {
let category = GearCategory::from_str(&self.category)
.ok_or_else(|| AppError::validation("invalid category"))?;
if self.make.trim().is_empty() {
return Err(AppError::validation("make cannot be empty"));
}
if self.model.trim().is_empty() {
return Err(AppError::validation("model cannot be empty"));
}
Ok(NewGear {
category,
make: self.make,
model: self.model,
notes: self.notes.filter(|s| !s.trim().is_empty()),
})
}
}
async fn render_gear_list_fragment(
state: AppState,
request: ListRequest<GearSortKey>,
is_authenticated: bool,
) -> Result<Response, AppError> {
let gear = load_gear_page(&state, request).await?;
let navigator = ListNavigator::new(GEAR_PAGE_PATH, GEAR_FRAGMENT_PATH, request);
let template = GearListTemplate {
is_authenticated,
gear,
navigator,
};
crate::application::routes::support::render_fragment(template, "#gear-list")
}

View file

@ -1,5 +1,6 @@
pub mod auth; pub mod auth;
pub mod bags; pub mod bags;
pub mod gear;
mod macros; mod macros;
pub mod roasters; pub mod roasters;
pub mod roasts; pub mod roasts;
@ -49,6 +50,13 @@ pub fn app_router(state: AppState) -> axum::Router {
.put(bags::update_bag) .put(bags::update_bag)
.delete(bags::delete_bag), .delete(bags::delete_bag),
) )
.route("/gear", get(gear::list_gear).post(gear::create_gear))
.route(
"/gear/:id",
get(gear::get_gear)
.put(gear::update_gear)
.delete(gear::delete_gear),
)
.route( .route(
"/tokens", "/tokens",
post(tokens::create_token).get(tokens::list_tokens), post(tokens::create_token).get(tokens::list_tokens),
@ -67,6 +75,7 @@ pub fn app_router(state: AppState) -> axum::Router {
get(roasts::roast_page), get(roasts::roast_page),
) )
.route("/bags", get(bags::bags_page)) .route("/bags", get(bags::bags_page))
.route("/gear", get(gear::gear_page))
.route("/timeline", get(timeline::timeline_page)) .route("/timeline", get(timeline::timeline_page))
.route("/styles.css", get(styles)) .route("/styles.css", get(styles))
.route("/favicon.ico", get(favicon)) .route("/favicon.ico", get(favicon))

View file

@ -9,13 +9,14 @@ use tracing::info;
use crate::application::routes::app_router; use crate::application::routes::app_router;
use crate::domain::repositories::{ use crate::domain::repositories::{
BagRepository, RoastRepository, RoasterRepository, SessionRepository, TimelineEventRepository, BagRepository, GearRepository, RoastRepository, RoasterRepository, SessionRepository,
TokenRepository, UserRepository, TimelineEventRepository, TokenRepository, UserRepository,
}; };
use crate::domain::users::NewUser; use crate::domain::users::NewUser;
use crate::infrastructure::auth::hash_password; use crate::infrastructure::auth::hash_password;
use crate::infrastructure::database::Database; use crate::infrastructure::database::Database;
use crate::infrastructure::repositories::bags::SqlBagRepository; use crate::infrastructure::repositories::bags::SqlBagRepository;
use crate::infrastructure::repositories::gear::SqlGearRepository;
use crate::infrastructure::repositories::roasters::SqlRoasterRepository; use crate::infrastructure::repositories::roasters::SqlRoasterRepository;
use crate::infrastructure::repositories::roasts::SqlRoastRepository; use crate::infrastructure::repositories::roasts::SqlRoastRepository;
use crate::infrastructure::repositories::sessions::SqlSessionRepository; use crate::infrastructure::repositories::sessions::SqlSessionRepository;
@ -35,6 +36,7 @@ pub struct AppState {
pub roaster_repo: Arc<dyn RoasterRepository>, pub roaster_repo: Arc<dyn RoasterRepository>,
pub roast_repo: Arc<dyn RoastRepository>, pub roast_repo: Arc<dyn RoastRepository>,
pub bag_repo: Arc<dyn BagRepository>, pub bag_repo: Arc<dyn BagRepository>,
pub gear_repo: Arc<dyn GearRepository>,
pub timeline_repo: Arc<dyn TimelineEventRepository>, pub timeline_repo: Arc<dyn TimelineEventRepository>,
pub user_repo: Arc<dyn UserRepository>, pub user_repo: Arc<dyn UserRepository>,
pub token_repo: Arc<dyn TokenRepository>, pub token_repo: Arc<dyn TokenRepository>,
@ -46,6 +48,7 @@ impl AppState {
roaster_repo: Arc<dyn RoasterRepository>, roaster_repo: Arc<dyn RoasterRepository>,
roast_repo: Arc<dyn RoastRepository>, roast_repo: Arc<dyn RoastRepository>,
bag_repo: Arc<dyn BagRepository>, bag_repo: Arc<dyn BagRepository>,
gear_repo: Arc<dyn GearRepository>,
timeline_repo: Arc<dyn TimelineEventRepository>, timeline_repo: Arc<dyn TimelineEventRepository>,
user_repo: Arc<dyn UserRepository>, user_repo: Arc<dyn UserRepository>,
token_repo: Arc<dyn TokenRepository>, token_repo: Arc<dyn TokenRepository>,
@ -55,6 +58,7 @@ impl AppState {
roaster_repo, roaster_repo,
roast_repo, roast_repo,
bag_repo, bag_repo,
gear_repo,
timeline_repo, timeline_repo,
user_repo, user_repo,
token_repo, token_repo,
@ -72,6 +76,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool())); let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool()));
let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool())); let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool()));
let bag_repo = Arc::new(SqlBagRepository::new(database.clone_pool())); let bag_repo = Arc::new(SqlBagRepository::new(database.clone_pool()));
let gear_repo = Arc::new(SqlGearRepository::new(database.clone_pool()));
let timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool())); let timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool()));
let user_repo: Arc<dyn UserRepository> = let user_repo: Arc<dyn UserRepository> =
Arc::new(SqlUserRepository::new(database.clone_pool())); Arc::new(SqlUserRepository::new(database.clone_pool()));
@ -87,6 +92,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
roaster_repo, roaster_repo,
roast_repo, roast_repo,
bag_repo, bag_repo,
gear_repo,
timeline_repo, timeline_repo,
user_repo, user_repo,
token_repo, token_repo,