feat(cafes): add route handlers and wire into application state
Add cafe route handlers (CRUD API + web pages with Datastar fragment support). Register cafe_repo in AppState and mount API and web routes.
This commit is contained in:
parent
677b77d70d
commit
1781bff48d
3 changed files with 211 additions and 2 deletions
193
src/application/routes/cafes.rs
Normal file
193
src/application/routes/cafes.rs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
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_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::{Cafe, CafeSortKey, NewCafe, UpdateCafe};
|
||||
use crate::domain::ids::CafeId;
|
||||
use crate::domain::listing::{ListRequest, SortDirection};
|
||||
use crate::presentation::web::templates::{CafeDetailTemplate, CafeListTemplate, CafesTemplate};
|
||||
use crate::presentation::web::views::{CafeView, ListNavigator, Paginated};
|
||||
|
||||
const CAFE_PAGE_PATH: &str = "/cafes";
|
||||
const CAFE_FRAGMENT_PATH: &str = "/cafes#cafe-list";
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
async fn load_cafe_page(
|
||||
state: &AppState,
|
||||
request: ListRequest<CafeSortKey>,
|
||||
search: Option<&str>,
|
||||
) -> Result<(Paginated<CafeView>, ListNavigator<CafeSortKey>), AppError> {
|
||||
let page = state
|
||||
.cafe_repo
|
||||
.list(&request, search)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
Ok(crate::application::routes::support::build_page_view(
|
||||
page,
|
||||
request,
|
||||
CafeView::from,
|
||||
CAFE_PAGE_PATH,
|
||||
CAFE_FRAGMENT_PATH,
|
||||
search.map(String::from),
|
||||
))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, cookies, headers, query))]
|
||||
pub(crate) async fn cafes_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::<CafeSortKey>();
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
return render_cafe_list_fragment(state, request, search, is_authenticated)
|
||||
.await
|
||||
.map_err(map_app_error);
|
||||
}
|
||||
|
||||
let (cafes, navigator) = load_cafe_page(&state, request, search.as_deref())
|
||||
.await
|
||||
.map_err(map_app_error)?;
|
||||
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
|
||||
let template = CafesTemplate {
|
||||
nav_active: "cafes",
|
||||
is_authenticated,
|
||||
cafes,
|
||||
navigator,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, cookies))]
|
||||
pub(crate) async fn cafe_page(
|
||||
State(state): State<AppState>,
|
||||
cookies: tower_cookies::Cookies,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let cafe = state
|
||||
.cafe_repo
|
||||
.get_by_slug(&slug)
|
||||
.await
|
||||
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||
|
||||
let cafe_view = CafeView::from(cafe);
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
|
||||
let template = CafeDetailTemplate {
|
||||
nav_active: "cafes",
|
||||
is_authenticated,
|
||||
cafe: cafe_view,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub(crate) async fn list_cafes(State(state): State<AppState>) -> Result<Json<Vec<Cafe>>, ApiError> {
|
||||
let cafes = state
|
||||
.cafe_repo
|
||||
.list_all_sorted(CafeSortKey::Name, SortDirection::Asc)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(cafes))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, _auth_user, headers, query))]
|
||||
pub(crate) async fn create_cafe(
|
||||
State(state): State<AppState>,
|
||||
_auth_user: AuthenticatedUser,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ListQuery>,
|
||||
payload: FlexiblePayload<NewCafe>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let (request, search) = query.into_request_and_search::<CafeSortKey>();
|
||||
let (new_cafe, source) = payload.into_parts();
|
||||
let new_cafe = new_cafe.normalize();
|
||||
let cafe = state
|
||||
.cafe_repo
|
||||
.insert(new_cafe)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
render_cafe_list_fragment(state, request, search, true)
|
||||
.await
|
||||
.map_err(ApiError::from)
|
||||
} else if matches!(source, PayloadSource::Form) {
|
||||
let target =
|
||||
ListNavigator::new(CAFE_PAGE_PATH, CAFE_FRAGMENT_PATH, request, search).page_href(1);
|
||||
Ok(Redirect::to(&target).into_response())
|
||||
} else {
|
||||
Ok((StatusCode::CREATED, Json(cafe)).into_response())
|
||||
}
|
||||
}
|
||||
|
||||
define_get_handler!(get_cafe, CafeId, Cafe, cafe_repo);
|
||||
|
||||
#[tracing::instrument(skip(state, _auth_user))]
|
||||
pub(crate) async fn update_cafe(
|
||||
State(state): State<AppState>,
|
||||
_auth_user: AuthenticatedUser,
|
||||
Path(id): Path<CafeId>,
|
||||
Json(payload): Json<UpdateCafe>,
|
||||
) -> Result<Json<Cafe>, ApiError> {
|
||||
let has_changes = payload.name.is_some()
|
||||
|| payload.city.is_some()
|
||||
|| payload.country.is_some()
|
||||
|| payload.latitude.is_some()
|
||||
|| payload.longitude.is_some()
|
||||
|| payload.website.is_some()
|
||||
|| payload.notes.is_some();
|
||||
|
||||
if !has_changes {
|
||||
return Err(AppError::validation("no changes provided").into());
|
||||
}
|
||||
|
||||
let cafe = state
|
||||
.cafe_repo
|
||||
.update(id, payload)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(cafe))
|
||||
}
|
||||
|
||||
define_delete_handler!(
|
||||
delete_cafe,
|
||||
CafeId,
|
||||
CafeSortKey,
|
||||
cafe_repo,
|
||||
render_cafe_list_fragment
|
||||
);
|
||||
|
||||
async fn render_cafe_list_fragment(
|
||||
state: AppState,
|
||||
request: ListRequest<CafeSortKey>,
|
||||
search: Option<String>,
|
||||
is_authenticated: bool,
|
||||
) -> Result<Response, AppError> {
|
||||
let (cafes, navigator) = load_cafe_page(&state, request, search.as_deref()).await?;
|
||||
|
||||
let template = CafeListTemplate {
|
||||
is_authenticated,
|
||||
cafes,
|
||||
navigator,
|
||||
};
|
||||
|
||||
crate::application::routes::support::render_fragment(template, "#cafe-list")
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
pub mod auth;
|
||||
pub mod bags;
|
||||
pub mod brews;
|
||||
pub mod cafes;
|
||||
pub mod gear;
|
||||
mod macros;
|
||||
pub mod roasters;
|
||||
|
|
@ -65,6 +66,13 @@ pub fn app_router(state: AppState) -> axum::Router {
|
|||
"/brews/:id",
|
||||
get(brews::get_brew).delete(brews::delete_brew),
|
||||
)
|
||||
.route("/cafes", get(cafes::list_cafes).post(cafes::create_cafe))
|
||||
.route(
|
||||
"/cafes/:id",
|
||||
get(cafes::get_cafe)
|
||||
.put(cafes::update_cafe)
|
||||
.delete(cafes::delete_cafe),
|
||||
)
|
||||
.route(
|
||||
"/tokens",
|
||||
post(tokens::create_token).get(tokens::list_tokens),
|
||||
|
|
@ -85,6 +93,8 @@ pub fn app_router(state: AppState) -> axum::Router {
|
|||
.route("/bags", get(bags::bags_page))
|
||||
.route("/brews", get(brews::brews_page))
|
||||
.route("/gear", get(gear::gear_page))
|
||||
.route("/cafes", get(cafes::cafes_page))
|
||||
.route("/cafes/:slug", get(cafes::cafe_page))
|
||||
.route("/timeline", get(timeline::timeline_page))
|
||||
.route("/styles.css", get(styles))
|
||||
.route("/favicon.ico", get(favicon))
|
||||
|
|
|
|||
|
|
@ -9,14 +9,15 @@ use tracing::info;
|
|||
|
||||
use crate::application::routes::app_router;
|
||||
use crate::domain::repositories::{
|
||||
BagRepository, BrewRepository, GearRepository, RoastRepository, RoasterRepository,
|
||||
SessionRepository, TimelineEventRepository, TokenRepository, UserRepository,
|
||||
BagRepository, BrewRepository, CafeRepository, 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::brews::SqlBrewRepository;
|
||||
use crate::infrastructure::repositories::cafes::SqlCafeRepository;
|
||||
use crate::infrastructure::repositories::gear::SqlGearRepository;
|
||||
use crate::infrastructure::repositories::roasters::SqlRoasterRepository;
|
||||
use crate::infrastructure::repositories::roasts::SqlRoastRepository;
|
||||
|
|
@ -39,6 +40,7 @@ pub struct AppState {
|
|||
pub bag_repo: Arc<dyn BagRepository>,
|
||||
pub gear_repo: Arc<dyn GearRepository>,
|
||||
pub brew_repo: Arc<dyn BrewRepository>,
|
||||
pub cafe_repo: Arc<dyn CafeRepository>,
|
||||
pub timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
pub user_repo: Arc<dyn UserRepository>,
|
||||
pub token_repo: Arc<dyn TokenRepository>,
|
||||
|
|
@ -53,6 +55,7 @@ impl AppState {
|
|||
bag_repo: Arc<dyn BagRepository>,
|
||||
gear_repo: Arc<dyn GearRepository>,
|
||||
brew_repo: Arc<dyn BrewRepository>,
|
||||
cafe_repo: Arc<dyn CafeRepository>,
|
||||
timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
token_repo: Arc<dyn TokenRepository>,
|
||||
|
|
@ -64,6 +67,7 @@ impl AppState {
|
|||
bag_repo,
|
||||
gear_repo,
|
||||
brew_repo,
|
||||
cafe_repo,
|
||||
timeline_repo,
|
||||
user_repo,
|
||||
token_repo,
|
||||
|
|
@ -83,6 +87,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
|||
let bag_repo = Arc::new(SqlBagRepository::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 cafe_repo = Arc::new(SqlCafeRepository::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()));
|
||||
|
|
@ -100,6 +105,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
|||
bag_repo,
|
||||
gear_repo,
|
||||
brew_repo,
|
||||
cafe_repo,
|
||||
timeline_repo,
|
||||
user_repo,
|
||||
token_repo,
|
||||
|
|
|
|||
Loading…
Reference in a new issue