use axum::Json; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::routing::{post, put}; use axum::{Router, routing::get}; use serde_json::json; use sqlx::SqlitePool; // Derive OpenApi for models mod auth; fn error_response(status: StatusCode, message: &str) -> axum::response::Response { (status, Json(json!({"error": message}))).into_response() } async fn not_found() -> axum::response::Response { error_response(StatusCode::NOT_FOUND, "Not Found") } mod users_api { use super::*; use crate::auth::AuthUser; use crate::users::{User, UserRepository, UserRole}; use axum::{ Json, extract::{Path, State}, }; use serde::Deserialize; use std::sync::Arc; #[derive(Deserialize)] pub struct CreateUser { pub role: Option, } #[derive(Deserialize)] pub struct UpdateUser { pub role: UserRole, } pub async fn list_users( AuthUser(_): AuthUser, State(pool): State>, ) -> Result>, String> { let repo = UserRepository { db: &pool }; repo.list_users().await.map(Json).map_err(|e| e.to_string()) } pub async fn get_user( Path(id): Path, AuthUser(_): AuthUser, State(pool): State>, ) -> Result, String> { let repo = UserRepository { db: &pool }; repo.get_user_by_id(id) .await .map_err(|e| e.to_string())? .map(Json) .ok_or_else(|| "User not found".to_string()) } pub async fn create_user( AuthUser(_): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = UserRepository { db: &pool }; repo.create_user(None, payload.role) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn update_user( Path(id): Path, AuthUser(_): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = UserRepository { db: &pool }; repo.update_user(id, payload.role) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn delete_user( Path(id): Path, AuthUser(_): AuthUser, State(pool): State>, ) -> Result<(), String> { let repo = UserRepository { db: &pool }; repo.delete_user(id).await.map_err(|e| e.to_string()) } } mod locations_api { use super::*; use crate::auth::AuthUser; use crate::locations::{Location, LocationRepository}; use axum::{ Json, extract::{Path, State}, }; use serde::Deserialize; use std::sync::Arc; #[derive(Deserialize)] pub struct CreateLocation { pub latitude: f64, pub longitude: f64, pub user_id: i64, } #[derive(Deserialize)] pub struct UpdateLocation { pub latitude: f64, pub longitude: f64, } pub async fn list_locations( AuthUser(_): AuthUser, State(pool): State>, ) -> Result>, String> { let repo = LocationRepository { db: &pool }; repo.list_locations() .await .map(Json) .map_err(|e| e.to_string()) } pub async fn get_location( Path(id): Path, AuthUser(_): AuthUser, State(pool): State>, ) -> Result, String> { let repo = LocationRepository { db: &pool }; repo.get_location(id) .await .map_err(|e| e.to_string())? .map(Json) .ok_or_else(|| "Location not found".to_string()) } pub async fn create_location( AuthUser(_): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = LocationRepository { db: &pool }; repo.create_location(payload.latitude, payload.longitude, payload.user_id) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn update_location( Path(id): Path, AuthUser(_): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = LocationRepository { db: &pool }; // user_id is not updated repo.update_location(id, payload.latitude, payload.longitude) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn delete_location( Path(id): Path, AuthUser(_): AuthUser, State(pool): State>, ) -> Result<(), String> { let repo = LocationRepository { db: &pool }; repo.delete_location(id).await.map_err(|e| e.to_string()) } } mod thresholds_api { use super::*; use crate::auth::AuthUser; use crate::weather_thresholds::{WeatherThreshold, WeatherThresholdRepository}; use axum::{ Json, extract::{Path, Query, State}, }; use serde::Deserialize; use std::sync::Arc; #[derive(Deserialize)] pub struct CreateThreshold { pub user_id: i64, pub condition_type: String, pub threshold_value: f64, pub operator: String, pub enabled: bool, pub description: Option, } #[derive(Deserialize)] pub struct UpdateThreshold { pub user_id: i64, pub condition_type: String, pub threshold_value: f64, pub operator: String, pub enabled: bool, pub description: Option, } pub async fn list_thresholds( AuthUser(_): AuthUser, State(pool): State>, Query(query): Query>, ) -> impl axum::response::IntoResponse { let repo = WeatherThresholdRepository { db: &pool }; let user_id = query .get("user_id") .and_then(|s| s.parse().ok()) .ok_or_else(|| "user_id required as query param".to_string())?; repo.list_thresholds(user_id) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn get_threshold( Path((id, user_id)): Path<(i64, i64)>, AuthUser(_): AuthUser, State(pool): State>, ) -> Result, String> { let repo = WeatherThresholdRepository { db: &pool }; repo.get_threshold(id, user_id) .await .map_err(|e| e.to_string())? .map(Json) .ok_or_else(|| "Threshold not found".to_string()) } pub async fn create_threshold( AuthUser(_): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = WeatherThresholdRepository { db: &pool }; repo.create_threshold( payload.user_id, payload.condition_type, payload.threshold_value, payload.operator, payload.enabled, payload.description, ) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn update_threshold( Path(id): Path, AuthUser(_): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = WeatherThresholdRepository { db: &pool }; repo.update_threshold( id, payload.user_id, payload.condition_type, payload.threshold_value, payload.operator, payload.enabled, payload.description, ) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn delete_threshold( Path((id, user_id)): Path<(i64, i64)>, AuthUser(_): AuthUser, State(pool): State>, ) -> Result<(), String> { let repo = WeatherThresholdRepository { db: &pool }; repo.delete_threshold(id, user_id) .await .map_err(|e| e.to_string()) } } mod notifications_api { use super::*; use crate::auth::AuthUser; use crate::notifications::{ NtfySettings, NtfySettingsRepository, SmtpSettings, SmtpSettingsRepository, }; use axum::{ Json, extract::{Path, State}, }; use serde::Deserialize; use std::sync::Arc; // NTFY #[derive(Deserialize)] pub struct CreateNtfy { pub user_id: i64, pub enabled: bool, pub topic: String, pub server_url: String, pub priority: i32, pub title_template: Option, pub message_template: Option, } #[derive(Deserialize)] pub struct UpdateNtfy { pub enabled: bool, pub topic: String, pub server_url: String, pub priority: i32, pub title_template: Option, pub message_template: Option, } pub async fn get_ntfy_settings( Path(user_id): Path, AuthUser(_): AuthUser, State(pool): State>, ) -> Result, String> { let repo = NtfySettingsRepository { db: &pool }; repo.get_by_user(user_id) .await .map_err(|e| e.to_string())? .map(Json) .ok_or_else(|| "NTFY settings not found".to_string()) } pub async fn create_ntfy_settings( AuthUser(_): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = NtfySettingsRepository { db: &pool }; repo.create( payload.user_id, payload.enabled, payload.topic, payload.server_url, payload.priority, payload.title_template, payload.message_template, ) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn update_ntfy_settings( Path(id): Path, AuthUser(_): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = NtfySettingsRepository { db: &pool }; repo.update( id, payload.enabled, payload.topic, payload.server_url, payload.priority, payload.title_template, payload.message_template, ) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn delete_ntfy_settings( Path(id): Path, AuthUser(_): AuthUser, State(pool): State>, ) -> Result<(), String> { let repo = NtfySettingsRepository { db: &pool }; repo.delete(id).await.map_err(|e| e.to_string()) } // SMTP #[derive(Deserialize)] pub struct CreateSmtp { pub user_id: i64, pub enabled: bool, pub email: String, pub smtp_server: String, pub smtp_port: i32, pub username: Option, pub password: Option, pub use_tls: bool, pub from_email: Option, pub from_name: Option, pub subject_template: Option, pub body_template: Option, } #[derive(Deserialize)] pub struct UpdateSmtp { pub enabled: bool, pub email: String, pub smtp_server: String, pub smtp_port: i32, pub username: Option, pub password: Option, pub use_tls: bool, pub from_email: Option, pub from_name: Option, pub subject_template: Option, pub body_template: Option, } pub async fn get_smtp_settings( Path(user_id): Path, AuthUser(_): AuthUser, State(pool): State>, ) -> Result, String> { let repo = SmtpSettingsRepository { db: &pool }; repo.get_by_user(user_id) .await .map_err(|e| e.to_string())? .map(Json) .ok_or_else(|| "SMTP settings not found".to_string()) } pub async fn create_smtp_settings( AuthUser(_): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = SmtpSettingsRepository { db: &pool }; repo.create( payload.user_id, payload.enabled, payload.email, payload.smtp_server, payload.smtp_port, payload.username, payload.password, payload.use_tls, payload.from_email, payload.from_name, payload.subject_template, payload.body_template, ) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn update_smtp_settings( Path(id): Path, AuthUser(_): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = SmtpSettingsRepository { db: &pool }; repo.update( id, payload.enabled, payload.email, payload.smtp_server, payload.smtp_port, payload.username, payload.password, payload.use_tls, payload.from_email, payload.from_name, payload.subject_template, payload.body_template, ) .await .map(Json) .map_err(|e| e.to_string()) } pub async fn delete_smtp_settings( Path(id): Path, AuthUser(_): AuthUser, State(pool): State>, ) -> Result<(), String> { let repo = SmtpSettingsRepository { db: &pool }; repo.delete(id).await.map_err(|e| e.to_string()) } } pub fn app_with_state(pool: std::sync::Arc) -> Router { Router::new() .route("/health", get(crate::health::health_handler)) .nest("/api/users", Router::new() .route("/", get(users_api::list_users).post(users_api::create_user)) .route("/{id}", get(users_api::get_user).put(users_api::update_user).delete(users_api::delete_user)) ) .nest("/api/locations", Router::new() .route("/", get(locations_api::list_locations).post(locations_api::create_location)) .route("/{id}", get(locations_api::get_location).put(locations_api::update_location).delete(locations_api::delete_location)) ) .nest("/api/weather-thresholds", Router::new() .route("/", get(|auth_user, state, query: axum::extract::Query>| async move { thresholds_api::list_thresholds(auth_user, state, query).await }).post(thresholds_api::create_threshold)) .route("/{id}/{user_id}", get(thresholds_api::get_threshold).put(thresholds_api::update_threshold).delete(thresholds_api::delete_threshold)) ) .nest("/api/ntfy-settings", Router::new() .route("/user/{user_id}", get(notifications_api::get_ntfy_settings)) .route("/", post(notifications_api::create_ntfy_settings)) .route("/{id}", put(notifications_api::update_ntfy_settings).delete(notifications_api::delete_ntfy_settings)) ) .nest("/api/smtp-settings", Router::new() .route("/user/{user_id}", get(notifications_api::get_smtp_settings)) .route("/", post(notifications_api::create_smtp_settings)) .route("/{id}", put(notifications_api::update_smtp_settings).delete(notifications_api::delete_smtp_settings)) ) .fallback(not_found) .with_state(pool) } pub mod health; pub mod locations; pub mod notifications; pub mod users; pub mod weather_poller; pub mod weather_thresholds; #[cfg(test)] mod tests { use super::*; use axum::body::Body; use axum::body::to_bytes; use axum::http::{Request, StatusCode}; use tower::ServiceExt; // for `oneshot` #[tokio::test] async fn test_health_endpoint() { let app = app_with_state(std::sync::Arc::new( sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(), )); let response = app .oneshot( Request::builder() .uri("/health") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), 1024).await.unwrap(); assert_eq!(&body[..], b"{\"status\":\"ok\"}"); } }