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 auth;
|
||||||
pub mod bags;
|
pub mod bags;
|
||||||
pub mod brews;
|
pub mod brews;
|
||||||
|
pub mod cafes;
|
||||||
pub mod gear;
|
pub mod gear;
|
||||||
mod macros;
|
mod macros;
|
||||||
pub mod roasters;
|
pub mod roasters;
|
||||||
|
|
@ -65,6 +66,13 @@ pub fn app_router(state: AppState) -> axum::Router {
|
||||||
"/brews/:id",
|
"/brews/:id",
|
||||||
get(brews::get_brew).delete(brews::delete_brew),
|
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(
|
.route(
|
||||||
"/tokens",
|
"/tokens",
|
||||||
post(tokens::create_token).get(tokens::list_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("/bags", get(bags::bags_page))
|
||||||
.route("/brews", get(brews::brews_page))
|
.route("/brews", get(brews::brews_page))
|
||||||
.route("/gear", get(gear::gear_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("/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,14 +9,15 @@ 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, GearRepository, RoastRepository, RoasterRepository,
|
BagRepository, BrewRepository, CafeRepository, GearRepository, RoastRepository,
|
||||||
SessionRepository, TimelineEventRepository, TokenRepository, UserRepository,
|
RoasterRepository, SessionRepository, 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::brews::SqlBrewRepository;
|
use crate::infrastructure::repositories::brews::SqlBrewRepository;
|
||||||
|
use crate::infrastructure::repositories::cafes::SqlCafeRepository;
|
||||||
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;
|
||||||
|
|
@ -39,6 +40,7 @@ pub struct AppState {
|
||||||
pub bag_repo: Arc<dyn BagRepository>,
|
pub bag_repo: Arc<dyn BagRepository>,
|
||||||
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 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>,
|
||||||
|
|
@ -53,6 +55,7 @@ impl AppState {
|
||||||
bag_repo: Arc<dyn BagRepository>,
|
bag_repo: Arc<dyn BagRepository>,
|
||||||
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>,
|
||||||
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>,
|
||||||
|
|
@ -64,6 +67,7 @@ impl AppState {
|
||||||
bag_repo,
|
bag_repo,
|
||||||
gear_repo,
|
gear_repo,
|
||||||
brew_repo,
|
brew_repo,
|
||||||
|
cafe_repo,
|
||||||
timeline_repo,
|
timeline_repo,
|
||||||
user_repo,
|
user_repo,
|
||||||
token_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 bag_repo = Arc::new(SqlBagRepository::new(database.clone_pool()));
|
||||||
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 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()));
|
||||||
|
|
@ -100,6 +105,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
||||||
bag_repo,
|
bag_repo,
|
||||||
gear_repo,
|
gear_repo,
|
||||||
brew_repo,
|
brew_repo,
|
||||||
|
cafe_repo,
|
||||||
timeline_repo,
|
timeline_repo,
|
||||||
user_repo,
|
user_repo,
|
||||||
token_repo,
|
token_repo,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue