use axum::Json; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::routing::{post, put}; use axum::{Router, routing::get}; use clap::{Parser, Subcommand}; use serde_json::json; use sqlx::SqlitePool; use std::env; use std::net::SocketAddr; use std::sync::Arc; use tokio::fs; use tracing::{error, info}; use tracing_subscriber::{EnvFilter, FmtSubscriber}; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; use uuid::Uuid; mod auth; mod health; mod locations; mod notifications; mod users; mod weather_poller; mod weather_thresholds; // --- users_api --- 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, utoipa::ToSchema)] pub struct CreateUser { pub role: Option, } #[derive(Deserialize, utoipa::ToSchema)] pub struct UpdateUser { pub role: UserRole, } #[utoipa::path( get, path = "/api/users", responses( (status = 200, description = "List users", body = [User]), (status = 401, description = "Unauthorized") ), tag = "users" )] 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()) } #[utoipa::path( get, path = "/api/users/{id}", params( ("id" = i64, Path, description = "User ID") ), responses( (status = 200, description = "Get user by ID", body = User), (status = 404, description = "User not found"), (status = 401, description = "Unauthorized") ), tag = "users" )] 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()) } #[utoipa::path( post, path = "/api/users", request_body = CreateUser, responses( (status = 200, description = "Create user", body = User), (status = 401, description = "Unauthorized") ), tag = "users" )] 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()) } #[utoipa::path( put, path = "/api/users/{id}", params( ("id" = i64, Path, description = "User ID") ), request_body = UpdateUser, responses( (status = 200, description = "Update user", body = User), (status = 404, description = "User not found"), (status = 401, description = "Unauthorized") ), tag = "users" )] 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()) } #[utoipa::path( delete, path = "/api/users/{id}", params( ("id" = i64, Path, description = "User ID") ), responses( (status = 200, description = "User deleted"), (status = 404, description = "User not found"), (status = 401, description = "Unauthorized") ), tag = "users" )] 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()) } } // --- locations_api --- 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, utoipa::ToSchema)] pub struct CreateLocation { pub latitude: f64, pub longitude: f64, } #[derive(Deserialize, utoipa::ToSchema)] pub struct UpdateLocation { pub latitude: f64, pub longitude: f64, } #[utoipa::path( get, path = "/api/locations", responses( (status = 200, description = "List locations", body = [Location]), (status = 401, description = "Unauthorized") ), tag = "locations" )] 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()) } #[utoipa::path( get, path = "/api/locations/{id}", params( ("id" = i64, Path, description = "Location ID") ), responses( (status = 200, description = "Get location by ID", body = Location), (status = 404, description = "Location not found"), (status = 401, description = "Unauthorized") ), tag = "locations" )] 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()) } #[utoipa::path( post, path = "/api/locations", request_body = CreateLocation, responses( (status = 200, description = "Create location", body = Location), (status = 401, description = "Unauthorized") ), tag = "locations" )] pub async fn create_location( AuthUser(user): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = LocationRepository { db: &pool }; repo.create_location(payload.latitude, payload.longitude, user.id) .await .map(Json) .map_err(|e| e.to_string()) } #[utoipa::path( put, path = "/api/locations/{id}", params( ("id" = i64, Path, description = "Location ID") ), request_body = UpdateLocation, responses( (status = 200, description = "Update location", body = Location), (status = 404, description = "Location not found"), (status = 401, description = "Unauthorized") ), tag = "locations" )] pub async fn update_location( Path(id): Path, AuthUser(_): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = LocationRepository { db: &pool }; repo.update_location(id, payload.latitude, payload.longitude) .await .map(Json) .map_err(|e| e.to_string()) } #[utoipa::path( delete, path = "/api/locations/{id}", params( ("id" = i64, Path, description = "Location ID") ), responses( (status = 200, description = "Location deleted"), (status = 404, description = "Location not found"), (status = 401, description = "Unauthorized") ), tag = "locations" )] 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()) } } // --- thresholds_api --- mod thresholds_api { use super::*; use crate::auth::AuthUser; use crate::weather_thresholds::{WeatherThreshold, WeatherThresholdRepository}; use axum::{ Json, extract::{Path, State}, }; use serde::Deserialize; use std::sync::Arc; #[derive(Deserialize, utoipa::ToSchema)] pub struct CreateThreshold { pub condition_type: String, pub threshold_value: f64, pub operator: String, pub enabled: bool, pub description: Option, } #[derive(Deserialize, utoipa::ToSchema)] pub struct UpdateThreshold { pub condition_type: String, pub threshold_value: f64, pub operator: String, pub enabled: bool, pub description: Option, } #[utoipa::path( get, path = "/api/weather-thresholds", responses( (status = 200, description = "List weather thresholds", body = [WeatherThreshold]), (status = 401, description = "Unauthorized") ), tag = "weather-thresholds" )] pub async fn list_thresholds( AuthUser(user): AuthUser, State(pool): State>, ) -> impl axum::response::IntoResponse { let repo = WeatherThresholdRepository { db: &pool }; match repo.list_thresholds(user.id).await { Ok(thresholds) => Json(thresholds).into_response(), Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } #[utoipa::path( get, path = "/api/weather-thresholds/{id}", params( ("id" = i64, Path, description = "Threshold ID") ), responses( (status = 200, description = "Get weather threshold by ID", body = WeatherThreshold), (status = 404, description = "Threshold not found"), (status = 401, description = "Unauthorized") ), tag = "weather-thresholds" )] pub async fn get_threshold( Path(id): Path, AuthUser(user): 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()) } #[utoipa::path( post, path = "/api/weather-thresholds", request_body = CreateThreshold, responses( (status = 200, description = "Create weather threshold", body = WeatherThreshold), (status = 401, description = "Unauthorized") ), tag = "weather-thresholds" )] pub async fn create_threshold( AuthUser(user): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = WeatherThresholdRepository { db: &pool }; repo.create_threshold( user.id, payload.condition_type, payload.threshold_value, payload.operator, payload.enabled, payload.description, ) .await .map(Json) .map_err(|e| e.to_string()) } #[utoipa::path( put, path = "/api/weather-thresholds/{id}", params( ("id" = i64, Path, description = "Threshold ID") ), request_body = UpdateThreshold, responses( (status = 200, description = "Update weather threshold", body = WeatherThreshold), (status = 404, description = "Threshold not found"), (status = 401, description = "Unauthorized") ), tag = "weather-thresholds" )] pub async fn update_threshold( Path(id): Path, AuthUser(user): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = WeatherThresholdRepository { db: &pool }; repo.update_threshold(crate::weather_thresholds::WeatherThresholdUpdateInput { id, user_id: user.id, condition_type: payload.condition_type, threshold_value: payload.threshold_value, operator: payload.operator, enabled: payload.enabled, description: payload.description, }) .await .map(Json) .map_err(|e| e.to_string()) } #[utoipa::path( delete, path = "/api/weather-thresholds/{id}", params( ("id" = i64, Path, description = "Threshold ID") ), responses( (status = 200, description = "Threshold deleted"), (status = 404, description = "Threshold not found"), (status = 401, description = "Unauthorized") ), tag = "weather-thresholds" )] pub async fn delete_threshold( Path(id): Path, AuthUser(user): AuthUser, State(pool): State>, ) -> Result<(), String> { let repo = WeatherThresholdRepository { db: &pool }; repo.delete_threshold(id, user.id) .await .map_err(|e| e.to_string()) } } // --- notifications_api --- 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, utoipa::ToSchema)] pub struct CreateNtfy { pub enabled: bool, pub topic: String, pub server_url: String, pub priority: i32, pub title_template: Option, pub message_template: Option, } #[derive(Deserialize, utoipa::ToSchema)] pub struct UpdateNtfy { pub enabled: bool, pub topic: String, pub server_url: String, pub priority: i32, pub title_template: Option, pub message_template: Option, } #[utoipa::path( get, path = "/api/v1/settings/ntfy/me", responses( (status = 200, description = "Get NTFY settings for current user", body = NtfySettings), (status = 404, description = "NTFY settings not found"), (status = 401, description = "Unauthorized") ), tag = "notifications" )] pub async fn get_ntfy_settings( AuthUser(user): 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()) } #[utoipa::path( post, path = "/api/v1/settings/ntfy", request_body = CreateNtfy, responses( (status = 200, description = "Create NTFY settings", body = NtfySettings), (status = 401, description = "Unauthorized") ), tag = "notifications" )] pub async fn create_ntfy_settings( AuthUser(user): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = NtfySettingsRepository { db: &pool }; repo.create(crate::notifications::NtfySettingsInput { user_id: user.id, enabled: payload.enabled, topic: payload.topic, server_url: payload.server_url, priority: payload.priority, title_template: payload.title_template, message_template: payload.message_template, }) .await .map(Json) .map_err(|e| e.to_string()) } #[utoipa::path( put, path = "/api/v1/settings/ntfy/{id}", params( ("id" = i64, Path, description = "NTFY settings ID") ), request_body = UpdateNtfy, responses( (status = 200, description = "Update NTFY settings", body = NtfySettings), (status = 404, description = "NTFY settings not found"), (status = 401, description = "Unauthorized") ), tag = "notifications" )] pub async fn update_ntfy_settings( Path(id): Path, AuthUser(user): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = NtfySettingsRepository { db: &pool }; repo.update( id, crate::notifications::NtfySettingsInput { user_id: user.id, enabled: payload.enabled, topic: payload.topic, server_url: payload.server_url, priority: payload.priority, title_template: payload.title_template, message_template: payload.message_template, }, ) .await .map(Json) .map_err(|e| e.to_string()) } #[utoipa::path( delete, path = "/api/v1/settings/ntfy/{id}", params( ("id" = i64, Path, description = "NTFY settings ID") ), responses( (status = 200, description = "NTFY settings deleted"), (status = 404, description = "NTFY settings not found"), (status = 401, description = "Unauthorized") ), tag = "notifications" )] 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, utoipa::ToSchema)] pub struct CreateSmtp { 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, utoipa::ToSchema)] 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, } #[utoipa::path( get, path = "/api/v1/settings/smtp/me", responses( (status = 200, description = "Get SMTP settings for current user", body = SmtpSettings), (status = 404, description = "SMTP settings not found"), (status = 401, description = "Unauthorized") ), tag = "notifications" )] pub async fn get_smtp_settings( AuthUser(user): 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()) } #[utoipa::path( post, path = "/api/v1/settings/smtp", request_body = CreateSmtp, responses( (status = 200, description = "Create SMTP settings", body = SmtpSettings), (status = 401, description = "Unauthorized") ), tag = "notifications" )] pub async fn create_smtp_settings( AuthUser(user): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = SmtpSettingsRepository { db: &pool }; repo.create(crate::notifications::SmtpSettingsInput { user_id: user.id, enabled: payload.enabled, email: payload.email, smtp_server: payload.smtp_server, smtp_port: payload.smtp_port, username: payload.username, password: payload.password, use_tls: payload.use_tls, from_email: payload.from_email, from_name: payload.from_name, subject_template: payload.subject_template, body_template: payload.body_template, }) .await .map(Json) .map_err(|e| e.to_string()) } #[utoipa::path( put, path = "/api/v1/settings/smtp/{id}", params( ("id" = i64, Path, description = "SMTP settings ID") ), request_body = UpdateSmtp, responses( (status = 200, description = "Update SMTP settings", body = SmtpSettings), (status = 404, description = "SMTP settings not found"), (status = 401, description = "Unauthorized") ), tag = "notifications" )] pub async fn update_smtp_settings( Path(id): Path, AuthUser(user): AuthUser, State(pool): State>, Json(payload): Json, ) -> Result, String> { let repo = SmtpSettingsRepository { db: &pool }; repo.update( id, crate::notifications::SmtpSettingsInput { user_id: user.id, enabled: payload.enabled, email: payload.email, smtp_server: payload.smtp_server, smtp_port: payload.smtp_port, username: payload.username, password: payload.password, use_tls: payload.use_tls, from_email: payload.from_email, from_name: payload.from_name, subject_template: payload.subject_template, body_template: payload.body_template, }, ) .await .map(Json) .map_err(|e| e.to_string()) } #[utoipa::path( delete, path = "/api/v1/settings/smtp/{id}", params( ("id" = i64, Path, description = "SMTP settings ID") ), responses( (status = 200, description = "SMTP settings deleted"), (status = 404, description = "SMTP settings not found"), (status = 401, description = "Unauthorized") ), tag = "notifications" )] 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()) } } // --- app_with_state --- pub fn error_response(status: StatusCode, message: &str) -> axum::response::Response { (status, Json(json!({"error": message}))).into_response() } pub async fn not_found() -> axum::response::Response { error_response(StatusCode::NOT_FOUND, "Not Found") } #[derive(OpenApi)] #[openapi( paths( users_api::list_users, users_api::get_user, users_api::create_user, users_api::update_user, users_api::delete_user, locations_api::list_locations, locations_api::get_location, locations_api::create_location, locations_api::update_location, locations_api::delete_location, thresholds_api::list_thresholds, thresholds_api::get_threshold, thresholds_api::create_threshold, thresholds_api::update_threshold, thresholds_api::delete_threshold, notifications_api::get_ntfy_settings, notifications_api::create_ntfy_settings, notifications_api::update_ntfy_settings, notifications_api::delete_ntfy_settings, notifications_api::get_smtp_settings, notifications_api::create_smtp_settings, notifications_api::update_smtp_settings, notifications_api::delete_smtp_settings, health::health_handler, ), components( schemas( users::User, users::UserRole, users_api::CreateUser, users_api::UpdateUser, locations::Location, locations_api::CreateLocation, locations_api::UpdateLocation, weather_thresholds::WeatherThreshold, thresholds_api::CreateThreshold, thresholds_api::UpdateThreshold, notifications::NtfySettings, notifications::SmtpSettings, notifications_api::CreateNtfy, notifications_api::UpdateNtfy, notifications_api::CreateSmtp, notifications_api::UpdateSmtp, ) ), tags( (name = "users", description = "User management endpoints"), (name = "locations", description = "Location management endpoints"), (name = "weather-thresholds", description = "Weather threshold endpoints"), (name = "notifications", description = "Notification settings endpoints"), (name = "health", description = "Health check endpoint"), ) )] pub struct ApiDoc; pub fn app_with_state(pool: std::sync::Arc) -> Router { Router::new() .route("/health", get(health::health_handler)) .merge(SwaggerUi::new("/swagger-ui").url("/openapi.json", ApiDoc::openapi())) .nest( "/api/v1", Router::new() .nest( "/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( "/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( "/weather-thresholds", Router::new() .route( "/", get(thresholds_api::list_thresholds) .post(thresholds_api::create_threshold), ) .route( "/{id}", get(thresholds_api::get_threshold) .put(thresholds_api::update_threshold) .delete(thresholds_api::delete_threshold), ), ) .nest( "/settings/ntfy", Router::new() .route("/me", 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( "/settings/smtp", Router::new() .route("/me", 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) } #[derive(Parser)] #[command(name = "silmataivas")] struct Cli { /// Increase output verbosity (-v, -vv, -vvv) #[arg(short, long, action = clap::ArgAction::Count, global = true)] verbose: u8, #[command(subcommand)] command: Option, } #[derive(Subcommand)] enum Commands { /// Start the Silmataivas server (default) Server, /// Create a new user with optional UUID CreateUser { uuid: Option }, } #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); // Set up logging based on verbosity let filter = match cli.verbose { 0 => "warn", 1 => "info", 2 => "debug", _ => "trace", }; let subscriber = FmtSubscriber::builder() .with_env_filter(EnvFilter::new(filter)) .finish(); tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); match cli.command.unwrap_or(Commands::Server) { Commands::Server => { // Set up database path let db_path = env::var("DATABASE_URL") .unwrap_or_else(|_| "sqlite://./data/silmataivas.db".to_string()); // Ensure data directory exists let _ = fs::create_dir_all("./data").await; // Connect to SQLite let pool = SqlitePool::connect(&db_path) .await .expect("Failed to connect to DB"); // Create initial admin user if none exists { let repo = users::UserRepository { db: &pool }; match repo.any_admin_exists().await { Ok(false) => { let admin_token = env::var("ADMIN_TOKEN").unwrap_or_else(|_| Uuid::new_v4().to_string()); match repo .create_user(Some(admin_token.clone()), Some(users::UserRole::Admin)) .await { Ok(_) => info!("Initial admin user created. Token: {}", admin_token), Err(e) => error!("Failed to create initial admin user: {}", e), } } Ok(true) => { // At least one admin exists, do nothing } Err(e) => { error!("Failed to check for existing admin users: {}", e); } } } let app = app_with_state(Arc::new(pool)); let addr = SocketAddr::from(([0, 0, 0, 0], 4000)); let listener = tokio::net::TcpListener::bind(addr) .await .expect("Failed to bind address"); info!("Listening on {}", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); } Commands::CreateUser { uuid } => { let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let pool = SqlitePool::connect(&db_url).await?; let repo = crate::users::UserRepository { db: &pool }; let user_id = uuid.unwrap_or_else(|| Uuid::new_v4().to_string()); let user = repo .create_user(Some(user_id.clone()), Some(crate::users::UserRole::User)) .await?; // Set up default NTFY settings let ntfy_repo = crate::notifications::NtfySettingsRepository { db: &pool }; ntfy_repo .create(crate::notifications::NtfySettingsInput { user_id: user.id, enabled: true, topic: user_id.clone(), server_url: "https://ntfy.sh".to_string(), priority: 3, title_template: None, message_template: None, }) .await?; // Set up default weather thresholds let threshold_repo = crate::weather_thresholds::WeatherThresholdRepository { db: &pool }; threshold_repo .create_threshold( user.id, "temperature".to_string(), 35.0, ">".to_string(), true, Some("Default: temperature > 35°C".to_string()), ) .await?; threshold_repo .create_threshold( user.id, "rain".to_string(), 50.0, ">".to_string(), true, Some("Default: rain > 50mm".to_string()), ) .await?; threshold_repo .create_threshold( user.id, "wind_speed".to_string(), 80.0, ">".to_string(), true, Some("Default: wind speed > 80km/h".to_string()), ) .await?; info!("User {} created", user_id); } } Ok(()) } #[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\"}"); } }