260 lines
11 KiB
Rust
260 lines
11 KiB
Rust
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<DatabaseConnection, DbErr> {
|
|
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<Router, DbErr> {
|
|
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<HeaderValue> = allowed_origins
|
|
.split(',')
|
|
.filter_map(|origin| origin.trim().parse::<HeaderValue>().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))
|
|
}
|