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:
parent
2309a696cc
commit
1ef447157e
3 changed files with 260 additions and 2 deletions
243
src/application/routes/gear.rs
Normal file
243
src/application/routes/gear.rs
Normal 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")
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod auth;
|
||||
pub mod bags;
|
||||
pub mod gear;
|
||||
mod macros;
|
||||
pub mod roasters;
|
||||
pub mod roasts;
|
||||
|
|
@ -49,6 +50,13 @@ pub fn app_router(state: AppState) -> axum::Router {
|
|||
.put(bags::update_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(
|
||||
"/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),
|
||||
)
|
||||
.route("/bags", get(bags::bags_page))
|
||||
.route("/gear", get(gear::gear_page))
|
||||
.route("/timeline", get(timeline::timeline_page))
|
||||
.route("/styles.css", get(styles))
|
||||
.route("/favicon.ico", get(favicon))
|
||||
|
|
|
|||
|
|
@ -9,13 +9,14 @@ use tracing::info;
|
|||
|
||||
use crate::application::routes::app_router;
|
||||
use crate::domain::repositories::{
|
||||
BagRepository, RoastRepository, RoasterRepository, SessionRepository, TimelineEventRepository,
|
||||
TokenRepository, UserRepository,
|
||||
BagRepository, GearRepository, RoastRepository, RoasterRepository, SessionRepository,
|
||||
TimelineEventRepository, TokenRepository, UserRepository,
|
||||
};
|
||||
use crate::domain::users::NewUser;
|
||||
use crate::infrastructure::auth::hash_password;
|
||||
use crate::infrastructure::database::Database;
|
||||
use crate::infrastructure::repositories::bags::SqlBagRepository;
|
||||
use crate::infrastructure::repositories::gear::SqlGearRepository;
|
||||
use crate::infrastructure::repositories::roasters::SqlRoasterRepository;
|
||||
use crate::infrastructure::repositories::roasts::SqlRoastRepository;
|
||||
use crate::infrastructure::repositories::sessions::SqlSessionRepository;
|
||||
|
|
@ -35,6 +36,7 @@ pub struct AppState {
|
|||
pub roaster_repo: Arc<dyn RoasterRepository>,
|
||||
pub roast_repo: Arc<dyn RoastRepository>,
|
||||
pub bag_repo: Arc<dyn BagRepository>,
|
||||
pub gear_repo: Arc<dyn GearRepository>,
|
||||
pub timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
pub user_repo: Arc<dyn UserRepository>,
|
||||
pub token_repo: Arc<dyn TokenRepository>,
|
||||
|
|
@ -46,6 +48,7 @@ impl AppState {
|
|||
roaster_repo: Arc<dyn RoasterRepository>,
|
||||
roast_repo: Arc<dyn RoastRepository>,
|
||||
bag_repo: Arc<dyn BagRepository>,
|
||||
gear_repo: Arc<dyn GearRepository>,
|
||||
timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
token_repo: Arc<dyn TokenRepository>,
|
||||
|
|
@ -55,6 +58,7 @@ impl AppState {
|
|||
roaster_repo,
|
||||
roast_repo,
|
||||
bag_repo,
|
||||
gear_repo,
|
||||
timeline_repo,
|
||||
user_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 roast_repo = Arc::new(SqlRoastRepository::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 user_repo: Arc<dyn UserRepository> =
|
||||
Arc::new(SqlUserRepository::new(database.clone_pool()));
|
||||
|
|
@ -87,6 +92,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
|||
roaster_repo,
|
||||
roast_repo,
|
||||
bag_repo,
|
||||
gear_repo,
|
||||
timeline_repo,
|
||||
user_repo,
|
||||
token_repo,
|
||||
|
|
|
|||
Loading…
Reference in a new issue