summaryrefslogtreecommitdiff
path: root/src/lib.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib.rs')
-rw-r--r--src/lib.rs540
1 files changed, 533 insertions, 7 deletions
diff --git a/src/lib.rs b/src/lib.rs
index f51483b..900b4dc 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,29 +1,555 @@
+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;
-pub fn app() -> Router {
+// 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<UserRole>,
+ }
+
+ #[derive(Deserialize)]
+ pub struct UpdateUser {
+ pub role: UserRole,
+ }
+
+ pub async fn list_users(
+ AuthUser(_): AuthUser,
+ State(pool): State<Arc<SqlitePool>>,
+ ) -> Result<Json<Vec<User>>, 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<i64>,
+ AuthUser(_): AuthUser,
+ State(pool): State<Arc<SqlitePool>>,
+ ) -> Result<Json<User>, 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<Arc<SqlitePool>>,
+ Json(payload): Json<CreateUser>,
+ ) -> Result<Json<User>, 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<i64>,
+ AuthUser(_): AuthUser,
+ State(pool): State<Arc<SqlitePool>>,
+ Json(payload): Json<UpdateUser>,
+ ) -> Result<Json<User>, 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<i64>,
+ AuthUser(_): AuthUser,
+ State(pool): State<Arc<SqlitePool>>,
+ ) -> 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<Arc<SqlitePool>>,
+ ) -> Result<Json<Vec<Location>>, 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<i64>,
+ AuthUser(_): AuthUser,
+ State(pool): State<Arc<SqlitePool>>,
+ ) -> Result<Json<Location>, 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<Arc<SqlitePool>>,
+ Json(payload): Json<CreateLocation>,
+ ) -> Result<Json<Location>, 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<i64>,
+ AuthUser(_): AuthUser,
+ State(pool): State<Arc<SqlitePool>>,
+ Json(payload): Json<UpdateLocation>,
+ ) -> Result<Json<Location>, 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<i64>,
+ AuthUser(_): AuthUser,
+ State(pool): State<Arc<SqlitePool>>,
+ ) -> 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<String>,
+ }
+
+ #[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<String>,
+ }
+
+ pub async fn list_thresholds(
+ AuthUser(_): AuthUser,
+ State(pool): State<Arc<SqlitePool>>,
+ Query(query): Query<std::collections::HashMap<String, String>>,
+ ) -> 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<Arc<SqlitePool>>,
+ ) -> Result<Json<WeatherThreshold>, 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<Arc<SqlitePool>>,
+ Json(payload): Json<CreateThreshold>,
+ ) -> Result<Json<WeatherThreshold>, 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<i64>,
+ AuthUser(_): AuthUser,
+ State(pool): State<Arc<SqlitePool>>,
+ Json(payload): Json<UpdateThreshold>,
+ ) -> Result<Json<WeatherThreshold>, 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<Arc<SqlitePool>>,
+ ) -> 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<String>,
+ pub message_template: Option<String>,
+ }
+ #[derive(Deserialize)]
+ pub struct UpdateNtfy {
+ pub enabled: bool,
+ pub topic: String,
+ pub server_url: String,
+ pub priority: i32,
+ pub title_template: Option<String>,
+ pub message_template: Option<String>,
+ }
+ pub async fn get_ntfy_settings(
+ Path(user_id): Path<i64>,
+ AuthUser(_): AuthUser,
+ State(pool): State<Arc<SqlitePool>>,
+ ) -> Result<Json<NtfySettings>, 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<Arc<SqlitePool>>,
+ Json(payload): Json<CreateNtfy>,
+ ) -> Result<Json<NtfySettings>, 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<i64>,
+ AuthUser(_): AuthUser,
+ State(pool): State<Arc<SqlitePool>>,
+ Json(payload): Json<UpdateNtfy>,
+ ) -> Result<Json<NtfySettings>, 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<i64>,
+ AuthUser(_): AuthUser,
+ State(pool): State<Arc<SqlitePool>>,
+ ) -> 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<String>,
+ pub password: Option<String>,
+ pub use_tls: bool,
+ pub from_email: Option<String>,
+ pub from_name: Option<String>,
+ pub subject_template: Option<String>,
+ pub body_template: Option<String>,
+ }
+ #[derive(Deserialize)]
+ pub struct UpdateSmtp {
+ pub enabled: bool,
+ pub email: String,
+ pub smtp_server: String,
+ pub smtp_port: i32,
+ pub username: Option<String>,
+ pub password: Option<String>,
+ pub use_tls: bool,
+ pub from_email: Option<String>,
+ pub from_name: Option<String>,
+ pub subject_template: Option<String>,
+ pub body_template: Option<String>,
+ }
+ pub async fn get_smtp_settings(
+ Path(user_id): Path<i64>,
+ AuthUser(_): AuthUser,
+ State(pool): State<Arc<SqlitePool>>,
+ ) -> Result<Json<SmtpSettings>, 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<Arc<SqlitePool>>,
+ Json(payload): Json<CreateSmtp>,
+ ) -> Result<Json<SmtpSettings>, 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<i64>,
+ AuthUser(_): AuthUser,
+ State(pool): State<Arc<SqlitePool>>,
+ Json(payload): Json<UpdateSmtp>,
+ ) -> Result<Json<SmtpSettings>, 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<i64>,
+ AuthUser(_): AuthUser,
+ State(pool): State<Arc<SqlitePool>>,
+ ) -> 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<SqlitePool>) -> 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<std::collections::HashMap<String, String>>| 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 users;
+pub mod health;
pub mod locations;
-pub mod weather_thresholds;
pub mod notifications;
+pub mod users;
pub mod weather_poller;
-pub mod health;
+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`
- use axum::body::to_bytes;
#[tokio::test]
async fn test_health_endpoint() {
- let app = app();
- let response = app.oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap()).await.unwrap();
+ 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\"}");