- 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
213 lines
7 KiB
Rust
213 lines
7 KiB
Rust
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::Context;
|
|
use axum::Router;
|
|
use tokio::net::TcpListener;
|
|
use tokio::signal;
|
|
use tracing::info;
|
|
|
|
use crate::application::routes::app_router;
|
|
use crate::domain::repositories::{
|
|
BagRepository, BrewRepository, CafeRepository, CupRepository, 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::cups::SqlCupRepository;
|
|
use crate::infrastructure::repositories::gear::SqlGearRepository;
|
|
use crate::infrastructure::repositories::roasters::SqlRoasterRepository;
|
|
use crate::infrastructure::repositories::roasts::SqlRoastRepository;
|
|
use crate::infrastructure::repositories::sessions::SqlSessionRepository;
|
|
use crate::infrastructure::repositories::timeline_events::SqlTimelineEventRepository;
|
|
use crate::infrastructure::repositories::tokens::SqlTokenRepository;
|
|
use crate::infrastructure::repositories::users::SqlUserRepository;
|
|
|
|
pub struct ServerConfig {
|
|
pub bind_address: SocketAddr,
|
|
pub database_url: String,
|
|
pub admin_password: Option<String>,
|
|
pub admin_username: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
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 brew_repo: Arc<dyn BrewRepository>,
|
|
pub cafe_repo: Arc<dyn CafeRepository>,
|
|
pub cup_repo: Arc<dyn CupRepository>,
|
|
pub timeline_repo: Arc<dyn TimelineEventRepository>,
|
|
pub user_repo: Arc<dyn UserRepository>,
|
|
pub token_repo: Arc<dyn TokenRepository>,
|
|
pub session_repo: Arc<dyn SessionRepository>,
|
|
pub http_client: reqwest::Client,
|
|
pub nominatim_url: String,
|
|
}
|
|
|
|
impl AppState {
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new(
|
|
roaster_repo: Arc<dyn RoasterRepository>,
|
|
roast_repo: Arc<dyn RoastRepository>,
|
|
bag_repo: Arc<dyn BagRepository>,
|
|
gear_repo: Arc<dyn GearRepository>,
|
|
brew_repo: Arc<dyn BrewRepository>,
|
|
cafe_repo: Arc<dyn CafeRepository>,
|
|
cup_repo: Arc<dyn CupRepository>,
|
|
timeline_repo: Arc<dyn TimelineEventRepository>,
|
|
user_repo: Arc<dyn UserRepository>,
|
|
token_repo: Arc<dyn TokenRepository>,
|
|
session_repo: Arc<dyn SessionRepository>,
|
|
http_client: reqwest::Client,
|
|
nominatim_url: String,
|
|
) -> Self {
|
|
Self {
|
|
roaster_repo,
|
|
roast_repo,
|
|
bag_repo,
|
|
gear_repo,
|
|
brew_repo,
|
|
cafe_repo,
|
|
cup_repo,
|
|
timeline_repo,
|
|
user_repo,
|
|
token_repo,
|
|
session_repo,
|
|
http_client,
|
|
nominatim_url,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
|
let database = Database::connect(&config.database_url)
|
|
.await
|
|
.context("failed to connect to database")?;
|
|
database.migrate().await?;
|
|
|
|
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 brew_repo = Arc::new(SqlBrewRepository::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 user_repo: Arc<dyn UserRepository> =
|
|
Arc::new(SqlUserRepository::new(database.clone_pool()));
|
|
let token_repo: Arc<dyn TokenRepository> =
|
|
Arc::new(SqlTokenRepository::new(database.clone_pool()));
|
|
let session_repo: Arc<dyn SessionRepository> =
|
|
Arc::new(SqlSessionRepository::new(database.clone_pool()));
|
|
|
|
// Bootstrap admin user if no users exist
|
|
bootstrap_admin_user(&user_repo, config.admin_username, config.admin_password).await?;
|
|
|
|
let state = AppState::new(
|
|
roaster_repo,
|
|
roast_repo,
|
|
bag_repo,
|
|
gear_repo,
|
|
brew_repo,
|
|
cafe_repo,
|
|
cup_repo,
|
|
timeline_repo,
|
|
user_repo,
|
|
token_repo,
|
|
session_repo,
|
|
reqwest::Client::new(),
|
|
crate::infrastructure::osm::NOMINATIM_SEARCH_URL.to_string(),
|
|
);
|
|
|
|
let listener = TcpListener::bind(config.bind_address)
|
|
.await
|
|
.with_context(|| format!("failed to bind to {}", config.bind_address))?;
|
|
|
|
let app: Router = app_router(state);
|
|
|
|
info!(address = %config.bind_address, "starting HTTP server");
|
|
|
|
axum::serve(listener, app)
|
|
.with_graceful_shutdown(shutdown_signal())
|
|
.await
|
|
.context("server terminated unexpectedly")?;
|
|
|
|
info!("server shutdown complete");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn bootstrap_admin_user(
|
|
user_repo: &Arc<dyn UserRepository>,
|
|
admin_username: Option<String>,
|
|
admin_password: Option<String>,
|
|
) -> anyhow::Result<()> {
|
|
// Check if any users exist
|
|
let users_exist = user_repo
|
|
.exists()
|
|
.await
|
|
.context("failed to check if users exist")?;
|
|
|
|
if users_exist {
|
|
// Users already exist, no need to bootstrap
|
|
return Ok(());
|
|
}
|
|
|
|
// No users exist - we need to create the admin user
|
|
let username = admin_username.ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"No users exist in the database. Please provide BREWLOG_ADMIN_USERNAME \
|
|
environment variable to create the admin user."
|
|
)
|
|
})?;
|
|
|
|
let password = admin_password.ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"No users exist in the database. Please provide BREWLOG_ADMIN_PASSWORD \
|
|
environment variable to create the admin user."
|
|
)
|
|
})?;
|
|
|
|
info!("No users found. Creating admin user '{}'...", username);
|
|
|
|
let password_hash = hash_password(&password).context("failed to hash admin password")?;
|
|
|
|
let admin_user = NewUser::new(username, password_hash);
|
|
|
|
user_repo
|
|
.insert(admin_user)
|
|
.await
|
|
.context("failed to create admin user")?;
|
|
|
|
info!("Admin user created successfully");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(clippy::expect_used)] // Startup: panicking is appropriate if signal handlers fail
|
|
async fn shutdown_signal() {
|
|
let ctrl_c = async {
|
|
signal::ctrl_c()
|
|
.await
|
|
.expect("failed to install Ctrl+C handler");
|
|
};
|
|
|
|
let terminate = async {
|
|
signal::unix::signal(signal::unix::SignalKind::terminate())
|
|
.expect("failed to install SIGTERM handler")
|
|
.recv()
|
|
.await;
|
|
};
|
|
|
|
tokio::select! {
|
|
() = ctrl_c => {},
|
|
() = terminate => {},
|
|
}
|
|
}
|