feat(cups): add route handlers and wire into application state
- Add cup_repo to AppState and server startup - Register API routes: GET/POST /cups, GET/PUT/DELETE /cups/:id - Register web route: GET /cups with Datastar fragment support - Rating validation (1-5) on create and update
This commit is contained in:
parent
c2e9c7395c
commit
a6fad343fc
3 changed files with 217 additions and 1 deletions
201
src/application/routes/cups.rs
Normal file
201
src/application/routes/cups.rs
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
use axum::response::{IntoResponse, Redirect, Response};
|
||||||
|
|
||||||
|
use super::macros::{define_delete_handler, define_enriched_get_handler};
|
||||||
|
use crate::application::auth::AuthenticatedUser;
|
||||||
|
use crate::application::errors::{ApiError, AppError, map_app_error};
|
||||||
|
use crate::application::routes::render_html;
|
||||||
|
use crate::application::routes::support::{
|
||||||
|
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request,
|
||||||
|
};
|
||||||
|
use crate::application::server::AppState;
|
||||||
|
use crate::domain::cafes::CafeSortKey;
|
||||||
|
use crate::domain::cups::{Cup, CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup};
|
||||||
|
use crate::domain::ids::CupId;
|
||||||
|
use crate::domain::listing::{ListRequest, SortDirection};
|
||||||
|
use crate::presentation::web::templates::{CupListTemplate, CupsTemplate};
|
||||||
|
use crate::presentation::web::views::{
|
||||||
|
CafeOptionView, CupView, ListNavigator, Paginated, RoastOptionView,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CUP_PAGE_PATH: &str = "/cups";
|
||||||
|
const CUP_FRAGMENT_PATH: &str = "/cups#cup-list";
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state))]
|
||||||
|
async fn load_cup_page(
|
||||||
|
state: &AppState,
|
||||||
|
request: ListRequest<CupSortKey>,
|
||||||
|
search: Option<&str>,
|
||||||
|
) -> Result<(Paginated<CupView>, ListNavigator<CupSortKey>), AppError> {
|
||||||
|
let page = state
|
||||||
|
.cup_repo
|
||||||
|
.list(CupFilter::all(), &request, search)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
Ok(crate::application::routes::support::build_page_view(
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
CupView::from_domain,
|
||||||
|
CUP_PAGE_PATH,
|
||||||
|
CUP_FRAGMENT_PATH,
|
||||||
|
search.map(String::from),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state, cookies, headers, query))]
|
||||||
|
pub(crate) async fn cups_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
cookies: tower_cookies::Cookies,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(query): Query<ListQuery>,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
let (request, search) = query.into_request_and_search::<CupSortKey>();
|
||||||
|
|
||||||
|
if is_datastar_request(&headers) {
|
||||||
|
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||||
|
return render_cup_list_fragment(state, request, search, is_authenticated)
|
||||||
|
.await
|
||||||
|
.map_err(map_app_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (cups, navigator) = load_cup_page(&state, request, search.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(map_app_error)?;
|
||||||
|
|
||||||
|
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||||
|
|
||||||
|
let roast_options: Vec<RoastOptionView> = state
|
||||||
|
.roast_repo
|
||||||
|
.list_all()
|
||||||
|
.await
|
||||||
|
.map_err(|e| map_app_error(AppError::from(e)))?
|
||||||
|
.into_iter()
|
||||||
|
.map(RoastOptionView::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let cafe_options: Vec<CafeOptionView> = state
|
||||||
|
.cafe_repo
|
||||||
|
.list_all_sorted(CafeSortKey::Name, SortDirection::Asc)
|
||||||
|
.await
|
||||||
|
.map_err(|e| map_app_error(AppError::from(e)))?
|
||||||
|
.into_iter()
|
||||||
|
.map(CafeOptionView::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let template = CupsTemplate {
|
||||||
|
nav_active: "cups",
|
||||||
|
is_authenticated,
|
||||||
|
cups,
|
||||||
|
roast_options,
|
||||||
|
cafe_options,
|
||||||
|
navigator,
|
||||||
|
};
|
||||||
|
|
||||||
|
render_html(template).map(IntoResponse::into_response)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state, _auth_user, headers, query))]
|
||||||
|
pub(crate) async fn create_cup(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_auth_user: AuthenticatedUser,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(query): Query<ListQuery>,
|
||||||
|
payload: FlexiblePayload<NewCup>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
let (request, search) = query.into_request_and_search::<CupSortKey>();
|
||||||
|
let (new_cup, source) = payload.into_parts();
|
||||||
|
|
||||||
|
if let Some(rating) = new_cup.rating
|
||||||
|
&& !(1..=5).contains(&rating)
|
||||||
|
{
|
||||||
|
return Err(AppError::validation("rating must be between 1 and 5").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let cup = state
|
||||||
|
.cup_repo
|
||||||
|
.insert(new_cup)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
if is_datastar_request(&headers) {
|
||||||
|
render_cup_list_fragment(state, request, search, true)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::from)
|
||||||
|
} else if matches!(source, PayloadSource::Form) {
|
||||||
|
let target =
|
||||||
|
ListNavigator::new(CUP_PAGE_PATH, CUP_FRAGMENT_PATH, request, search).page_href(1);
|
||||||
|
Ok(Redirect::to(&target).into_response())
|
||||||
|
} else {
|
||||||
|
Ok((StatusCode::CREATED, Json(cup)).into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state))]
|
||||||
|
pub(crate) async fn list_cups(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Vec<CupWithDetails>>, ApiError> {
|
||||||
|
let request = ListRequest::show_all(CupSortKey::CreatedAt, SortDirection::Desc);
|
||||||
|
let page = state
|
||||||
|
.cup_repo
|
||||||
|
.list(CupFilter::all(), &request, None)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
Ok(Json(page.items))
|
||||||
|
}
|
||||||
|
|
||||||
|
define_enriched_get_handler!(get_cup, CupId, CupWithDetails, cup_repo, get_with_details);
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state, _auth_user))]
|
||||||
|
pub(crate) async fn update_cup(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_auth_user: AuthenticatedUser,
|
||||||
|
Path(id): Path<CupId>,
|
||||||
|
Json(payload): Json<UpdateCup>,
|
||||||
|
) -> Result<Json<Cup>, ApiError> {
|
||||||
|
let has_changes = payload.notes.is_some() || payload.rating.is_some();
|
||||||
|
|
||||||
|
if !has_changes {
|
||||||
|
return Err(AppError::validation("no changes provided").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rating) = payload.rating.as_ref()
|
||||||
|
&& !(1..=5).contains(rating)
|
||||||
|
{
|
||||||
|
return Err(AppError::validation("rating must be between 1 and 5").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let cup = state
|
||||||
|
.cup_repo
|
||||||
|
.update(id, payload)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
Ok(Json(cup))
|
||||||
|
}
|
||||||
|
|
||||||
|
define_delete_handler!(
|
||||||
|
delete_cup,
|
||||||
|
CupId,
|
||||||
|
CupSortKey,
|
||||||
|
cup_repo,
|
||||||
|
render_cup_list_fragment
|
||||||
|
);
|
||||||
|
|
||||||
|
async fn render_cup_list_fragment(
|
||||||
|
state: AppState,
|
||||||
|
request: ListRequest<CupSortKey>,
|
||||||
|
search: Option<String>,
|
||||||
|
is_authenticated: bool,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let (cups, navigator) = load_cup_page(&state, request, search.as_deref()).await?;
|
||||||
|
|
||||||
|
let template = CupListTemplate {
|
||||||
|
is_authenticated,
|
||||||
|
cups,
|
||||||
|
navigator,
|
||||||
|
};
|
||||||
|
|
||||||
|
crate::application::routes::support::render_fragment(template, "#cup-list")
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ pub mod auth;
|
||||||
pub mod bags;
|
pub mod bags;
|
||||||
pub mod brews;
|
pub mod brews;
|
||||||
pub mod cafes;
|
pub mod cafes;
|
||||||
|
pub mod cups;
|
||||||
pub mod gear;
|
pub mod gear;
|
||||||
mod macros;
|
mod macros;
|
||||||
pub mod roasters;
|
pub mod roasters;
|
||||||
|
|
@ -74,6 +75,13 @@ pub fn app_router(state: AppState) -> axum::Router {
|
||||||
.delete(cafes::delete_cafe),
|
.delete(cafes::delete_cafe),
|
||||||
)
|
)
|
||||||
.route("/nearby-cafes", get(cafes::nearby_cafes))
|
.route("/nearby-cafes", get(cafes::nearby_cafes))
|
||||||
|
.route("/cups", get(cups::list_cups).post(cups::create_cup))
|
||||||
|
.route(
|
||||||
|
"/cups/:id",
|
||||||
|
get(cups::get_cup)
|
||||||
|
.put(cups::update_cup)
|
||||||
|
.delete(cups::delete_cup),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/tokens",
|
"/tokens",
|
||||||
post(tokens::create_token).get(tokens::list_tokens),
|
post(tokens::create_token).get(tokens::list_tokens),
|
||||||
|
|
@ -96,6 +104,7 @@ pub fn app_router(state: AppState) -> axum::Router {
|
||||||
.route("/gear", get(gear::gear_page))
|
.route("/gear", get(gear::gear_page))
|
||||||
.route("/cafes", get(cafes::cafes_page))
|
.route("/cafes", get(cafes::cafes_page))
|
||||||
.route("/cafes/:slug", get(cafes::cafe_page))
|
.route("/cafes/:slug", get(cafes::cafe_page))
|
||||||
|
.route("/cups", get(cups::cups_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,7 +9,7 @@ use tracing::info;
|
||||||
|
|
||||||
use crate::application::routes::app_router;
|
use crate::application::routes::app_router;
|
||||||
use crate::domain::repositories::{
|
use crate::domain::repositories::{
|
||||||
BagRepository, BrewRepository, CafeRepository, GearRepository, RoastRepository,
|
BagRepository, BrewRepository, CafeRepository, CupRepository, GearRepository, RoastRepository,
|
||||||
RoasterRepository, SessionRepository, TimelineEventRepository, TokenRepository, UserRepository,
|
RoasterRepository, SessionRepository, TimelineEventRepository, TokenRepository, UserRepository,
|
||||||
};
|
};
|
||||||
use crate::domain::users::NewUser;
|
use crate::domain::users::NewUser;
|
||||||
|
|
@ -18,6 +18,7 @@ use crate::infrastructure::database::Database;
|
||||||
use crate::infrastructure::repositories::bags::SqlBagRepository;
|
use crate::infrastructure::repositories::bags::SqlBagRepository;
|
||||||
use crate::infrastructure::repositories::brews::SqlBrewRepository;
|
use crate::infrastructure::repositories::brews::SqlBrewRepository;
|
||||||
use crate::infrastructure::repositories::cafes::SqlCafeRepository;
|
use crate::infrastructure::repositories::cafes::SqlCafeRepository;
|
||||||
|
use crate::infrastructure::repositories::cups::SqlCupRepository;
|
||||||
use crate::infrastructure::repositories::gear::SqlGearRepository;
|
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;
|
||||||
|
|
@ -41,6 +42,7 @@ pub struct AppState {
|
||||||
pub gear_repo: Arc<dyn GearRepository>,
|
pub gear_repo: Arc<dyn GearRepository>,
|
||||||
pub brew_repo: Arc<dyn BrewRepository>,
|
pub brew_repo: Arc<dyn BrewRepository>,
|
||||||
pub cafe_repo: Arc<dyn CafeRepository>,
|
pub cafe_repo: Arc<dyn CafeRepository>,
|
||||||
|
pub cup_repo: Arc<dyn CupRepository>,
|
||||||
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>,
|
||||||
|
|
@ -58,6 +60,7 @@ impl AppState {
|
||||||
gear_repo: Arc<dyn GearRepository>,
|
gear_repo: Arc<dyn GearRepository>,
|
||||||
brew_repo: Arc<dyn BrewRepository>,
|
brew_repo: Arc<dyn BrewRepository>,
|
||||||
cafe_repo: Arc<dyn CafeRepository>,
|
cafe_repo: Arc<dyn CafeRepository>,
|
||||||
|
cup_repo: Arc<dyn CupRepository>,
|
||||||
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>,
|
||||||
|
|
@ -72,6 +75,7 @@ impl AppState {
|
||||||
gear_repo,
|
gear_repo,
|
||||||
brew_repo,
|
brew_repo,
|
||||||
cafe_repo,
|
cafe_repo,
|
||||||
|
cup_repo,
|
||||||
timeline_repo,
|
timeline_repo,
|
||||||
user_repo,
|
user_repo,
|
||||||
token_repo,
|
token_repo,
|
||||||
|
|
@ -94,6 +98,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
||||||
let gear_repo = Arc::new(SqlGearRepository::new(database.clone_pool()));
|
let gear_repo = Arc::new(SqlGearRepository::new(database.clone_pool()));
|
||||||
let brew_repo = Arc::new(SqlBrewRepository::new(database.clone_pool()));
|
let brew_repo = Arc::new(SqlBrewRepository::new(database.clone_pool()));
|
||||||
let cafe_repo = Arc::new(SqlCafeRepository::new(database.clone_pool()));
|
let cafe_repo = Arc::new(SqlCafeRepository::new(database.clone_pool()));
|
||||||
|
let cup_repo = Arc::new(SqlCupRepository::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()));
|
||||||
|
|
@ -112,6 +117,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
||||||
gear_repo,
|
gear_repo,
|
||||||
brew_repo,
|
brew_repo,
|
||||||
cafe_repo,
|
cafe_repo,
|
||||||
|
cup_repo,
|
||||||
timeline_repo,
|
timeline_repo,
|
||||||
user_repo,
|
user_repo,
|
||||||
token_repo,
|
token_repo,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue