use axum::{ routing::{get, post, put, delete}, Router, middleware as axum_middleware, }; use sea_orm::{sqlx, Database, DatabaseConnection, DbErr}; use sea_orm_migration::prelude::*; use std::net::SocketAddr; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; use tower_sessions::{Expiry, SessionManagerLayer, cookie::SameSite}; use tower_sessions_sqlx_store::PostgresStore; use axum_login::AuthManagerLayerBuilder; use time::Duration; use tower_http::cors::CorsLayer; use axum::http::{Method, HeaderValue}; pub mod models; pub mod services; pub mod migration; pub mod routes; pub mod auth; pub mod middleware; pub use auth::AuthBackend; pub use middleware::{require_admin, require_family_access}; #[derive(OpenApi)] #[openapi( paths( routes::auth::login, routes::auth::logout, routes::auth::me, routes::auth::family_login, routes::oauth::google_auth, routes::oauth::google_callback, routes::family::create_family, routes::family::create_my_family, routes::family::get_family, routes::family::get_all_families, routes::family::update_family, routes::family::delete_family, routes::category::create_category, routes::category::get_category, routes::category::get_categories_by_family, routes::category::update_category, routes::category::delete_category, routes::expense::create_expense, routes::expense::get_expense, routes::expense::get_expenses_by_category, routes::expense::update_expense, routes::expense::delete_expense, routes::expense::get_remaining_limit, routes::expense::get_history, routes::shopping_item::create_shopping_item, routes::shopping_item::get_shopping_items_by_family, routes::shopping_item::get_shopping_item, routes::shopping_item::update_shopping_item, routes::shopping_item::delete_shopping_item, routes::shopping_item::mark_as_purchased, routes::shopping_item::mark_all_as_purchased, routes::shopping_item::clear_all, routes::invite_link::create_invite_link, routes::invite_link::get_my_invite_links, routes::invite_link::delete_invite_link, routes::invite_link::validate_invite_link, routes::invite_link::join_family_via_invite, routes::user::leave_family, routes::user::get_family_members, ), components( schemas( models::family::Model, models::category::Model, models::expense::Model, models::shopping_item::Model, routes::auth::LoginRequest, routes::auth::LoginResponse, routes::auth::MeResponse, routes::auth::FamilyLoginRequest, routes::auth::FamilyLoginResponse, routes::oauth::OAuthUrlResponse, routes::family::CreateFamilyRequest, routes::family::CreateMyFamilyRequest, routes::family::CreateMyFamilyResponse, routes::family::UpdateFamilyRequest, routes::category::CreateCategoryRequest, routes::category::UpdateCategoryRequest, routes::expense::CreateExpenseRequest, routes::expense::UpdateExpenseRequest, routes::expense::RemainingLimitResponse, routes::expense::ExpenseHistoryResponse, routes::expense::MonthlyExpenseGroup, routes::shopping_item::CreateShoppingItemRequest, routes::shopping_item::UpdateShoppingItemRequest, routes::shopping_item::MarkAsPurchasedRequest, routes::shopping_item::BulkOperationResponse, models::invite_link::Model, routes::invite_link::CreateInviteLinkRequest, routes::invite_link::InviteLinkResponse, routes::invite_link::ValidateInviteResponse, routes::invite_link::JoinFamilyResponse, routes::user::LeaveFamilyResponse, routes::user::FamilyMember, ) ), tags( (name = "auth", description = "Authentication endpoints"), (name = "families", description = "Family management endpoints"), (name = "categories", description = "Category management endpoints"), (name = "expenses", description = "Expense management endpoints"), (name = "shopping-items", description = "Shopping list management endpoints"), (name = "invite-links", description = "Family invite link management endpoints"), (name = "user", description = "User profile management endpoints") ), info( title = "Family Budget API", version = "0.1.0", description = "REST API" ) )] struct ApiDoc; pub async fn establish_connection() -> Result { dotenvy::dotenv().ok(); let database_url = std::env::var("DATABASE_URL") .expect("DATABASE_URL must be set in .env file"); Database::connect(&database_url).await } pub async fn create_app(db: DatabaseConnection) -> Result { let database_url = std::env::var("DATABASE_URL") .expect("DATABASE_URL must be set in .env file"); let session_store = PostgresStore::new( sqlx::PgPool::connect(&database_url) .await .expect("Failed to connect to database for sessions"), ); session_store .migrate() .await .expect("Failed to run session store migrations"); let session_layer = SessionManagerLayer::new(session_store) .with_secure(true) .with_same_site(SameSite::Lax) .with_expiry(Expiry::OnInactivity(Duration::days(7))); let backend = auth::AuthBackend { db: db.clone() }; let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer.clone()).build(); let admin_family_routes = Router::new() .route("/families", post(routes::family::create_family)) .route("/families/{id}", delete(routes::family::delete_family)) .route_layer(axum_middleware::from_fn(middleware::require_admin)) .layer(auth_layer.clone()) .with_state(db.clone()); let auth_routes = Router::new() .route("/login", post(routes::auth::login)) .route("/logout", post(routes::auth::logout)) .route("/me", get(routes::auth::me)) .route("/me/leave-family", post(routes::user::leave_family)) .route("/my-family", post(routes::family::create_my_family)) .route("/auth/family-login", post(routes::auth::family_login)) .layer(auth_layer.clone()) .with_state(db.clone()); let oauth_routes = Router::new() .route("/auth/google", get(routes::oauth::google_auth)) .route("/auth/google/callback", get(routes::oauth::google_callback)) .layer(auth_layer.clone()) .with_state(db.clone()); let invite_link_routes = Router::new() .route("/my-family/invite-links", post(routes::invite_link::create_invite_link)) .route("/my-family/invite-links", get(routes::invite_link::get_my_invite_links)) .route("/my-family/invite-links/{token}", delete(routes::invite_link::delete_invite_link)) .route("/invite/{token}/join", post(routes::invite_link::join_family_via_invite)) .layer(auth_layer.clone()) .with_state(db.clone()); let family_protected_routes = Router::new() .route("/families/{family_id}/categories", post(routes::category::create_category)) .route("/families/{family_id}/categories", get(routes::category::get_categories_by_family)) .route("/families/{family_id}/categories/{category_id}", get(routes::category::get_category)) .route("/families/{family_id}/categories/{category_id}", put(routes::category::update_category)) .route("/families/{family_id}/categories/{category_id}", delete(routes::category::delete_category)) .route("/families/{family_id}/categories/{category_id}/expenses", post(routes::expense::create_expense)) .route("/families/{family_id}/categories/{category_id}/expenses", get(routes::expense::get_expenses_by_category)) .route("/families/{family_id}/categories/{category_id}/expenses/history", get(routes::expense::get_history)) .route("/families/{family_id}/categories/{category_id}/expenses/{expense_id}", get(routes::expense::get_expense)) .route("/families/{family_id}/categories/{category_id}/expenses/{expense_id}", put(routes::expense::update_expense)) .route("/families/{family_id}/categories/{category_id}/expenses/{expense_id}", delete(routes::expense::delete_expense)) .route("/families/{family_id}/categories/{category_id}/remaining", get(routes::expense::get_remaining_limit)) .route("/families/{family_id}/shopping-items", post(routes::shopping_item::create_shopping_item)) .route("/families/{family_id}/shopping-items", get(routes::shopping_item::get_shopping_items_by_family)) .route("/families/{family_id}/shopping-items/{id}", get(routes::shopping_item::get_shopping_item)) .route("/families/{family_id}/shopping-items/{id}", put(routes::shopping_item::update_shopping_item)) .route("/families/{family_id}/shopping-items/{id}", delete(routes::shopping_item::delete_shopping_item)) .route("/families/{family_id}/shopping-items/{id}/purchased", axum::routing::patch(routes::shopping_item::mark_as_purchased)) .route("/families/{family_id}/shopping-items/mark-all-purchased", post(routes::shopping_item::mark_all_as_purchased)) .route("/families/{family_id}/shopping-items/clear-all", delete(routes::shopping_item::clear_all)) .route("/families/{family_id}/members", get(routes::user::get_family_members)) .route_layer(axum_middleware::from_fn(middleware::require_family_access)) .layer(auth_layer.clone()) .with_state(db.clone()); let public_routes = Router::new() .route("/families", get(routes::family::get_all_families)) .route("/families/{id}", get(routes::family::get_family)) .route("/families/{id}", put(routes::family::update_family)) .route("/families/{id}/verify", post(routes::family::verify_family_password)) .route("/invite/{token}", get(routes::invite_link::validate_invite_link)) .layer(session_layer) .with_state(db); let api_routes = Router::new() .merge(admin_family_routes) .merge(auth_routes) .merge(oauth_routes) .merge(invite_link_routes) .merge(family_protected_routes) .merge(public_routes); let swagger_ui = SwaggerUi::new("/swagger-ui") .url("/api-docs/openapi.json", ApiDoc::openapi()); let allowed_origins = std::env::var("ALLOWED_ORIGINS") .unwrap_or_else(|_| "http://localhost:3000,http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:8080".to_string()); let origins: Vec = allowed_origins .split(',') .filter_map(|origin| origin.trim().parse::().ok()) .collect(); let cors = CorsLayer::new() .allow_origin(origins) .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS]) .allow_headers([ axum::http::header::CONTENT_TYPE, axum::http::header::AUTHORIZATION, axum::http::header::ACCEPT, ]) .allow_credentials(true); let app = Router::new() .nest("/api", api_routes) .merge(swagger_ui) .layer(cors); Ok(app) } pub fn server_address() -> SocketAddr { SocketAddr::from(([0, 0, 0, 0], 8080)) }