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 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))
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue