From e2930d1cd504571dfb218c9b9e2c64e932d3f6aa Mon Sep 17 00:00:00 2001 From: abhishekbhakat Date: Thu, 18 Dec 2025 18:46:56 +0530 Subject: [PATCH] feat: initialize rust backend with axum server, seaorm models, and project scaffolding --- .gitignore | 17 +++++ Makefile | 72 ++++++++++++++++++++++ PROJECT_MGMT/coding_standards.md | 9 ++- PROJECT_MGMT/phased_approach.md | 2 +- backend/Cargo.toml | 43 +++++++++++++ backend/sample.config.yaml | 25 ++++++++ backend/src/cli.rs | 33 ++++++++++ backend/src/config.rs | 71 +++++++++++++++++++++ backend/src/db.rs | 42 +++++++++++++ backend/src/main.rs | 75 +++++++++++++++++++++++ backend/src/models/bio/biomarker_entry.rs | 56 +++++++++++++++++ backend/src/models/bio/biomarker_type.rs | 43 +++++++++++++ backend/src/models/bio/mod.rs | 7 +++ backend/src/models/mod.rs | 7 +++ backend/src/models/user/mod.rs | 7 +++ backend/src/models/user/session.rs | 33 ++++++++++ backend/src/models/user/user.rs | 37 +++++++++++ 17 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 backend/Cargo.toml create mode 100644 backend/sample.config.yaml create mode 100644 backend/src/cli.rs create mode 100644 backend/src/config.rs create mode 100644 backend/src/db.rs create mode 100644 backend/src/main.rs create mode 100644 backend/src/models/bio/biomarker_entry.rs create mode 100644 backend/src/models/bio/biomarker_type.rs create mode 100644 backend/src/models/bio/mod.rs create mode 100644 backend/src/models/mod.rs create mode 100644 backend/src/models/user/mod.rs create mode 100644 backend/src/models/user/session.rs create mode 100644 backend/src/models/user/user.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bbf0752 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# BACKEND +target/ +Cargo.lock +config.yaml +backend/data/zhealth.db +backend/data/zhealth.db-wal +backend/data/zhealth.db-shm + +# FRONTEND + +# AI + +# OS +.DS_Store + +# IDE +.vscode/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..00deac9 --- /dev/null +++ b/Makefile @@ -0,0 +1,72 @@ +# zhealth Makefile +# Run `make help` to see available commands + +.PHONY: help dev build release lint typecheck test clean serve + +# Default target +help: + @echo "Available commands:" + @echo " make dev - Start development servers" + @echo " make build - Build for development" + @echo " make release - Build optimized production bundle" + @echo " make lint - Run linters (Clippy + ESLint)" + @echo " make typecheck - Type checking (Rust + TypeScript)" + @echo " make test - Run all tests" + @echo " make serve - Serve production build locally" + @echo " make clean - Clean build artifacts" + +# Backend commands +.PHONY: backend-dev backend-build backend-release backend-lint backend-test + +backend-dev: + cd backend && cargo run + +backend-build: + cd backend && cargo build + +backend-release: + cd backend && cargo build --release + +backend-lint: + cd backend && cargo clippy -- -D warnings + +backend-test: + cd backend && cargo test + +# Frontend commands (placeholder for when frontend is set up) +.PHONY: frontend-dev frontend-build frontend-release frontend-lint frontend-test + +frontend-dev: + @echo "Frontend not yet configured" + +frontend-build: + @echo "Frontend not yet configured" + +frontend-release: + @echo "Frontend not yet configured" + +frontend-lint: + @echo "Frontend not yet configured" + +frontend-test: + @echo "Frontend not yet configured" + +# Combined commands +dev: backend-dev + +build: backend-build frontend-build + +release: backend-release frontend-release + +lint: backend-lint frontend-lint + +typecheck: backend-lint frontend-lint + +test: backend-test frontend-test + +serve: + cd backend && cargo run --release + +clean: + cd backend && cargo clean + @echo "Cleaned backend artifacts" diff --git a/PROJECT_MGMT/coding_standards.md b/PROJECT_MGMT/coding_standards.md index cd9bb23..e56a880 100644 --- a/PROJECT_MGMT/coding_standards.md +++ b/PROJECT_MGMT/coding_standards.md @@ -89,12 +89,17 @@ paths: database: "./data/zhealth.db" logs: "./logs" +logging: + level: "info" # trace | debug | info | warn | error + auth: - jwt_secret: "${JWT_SECRET}" # Loaded from env - token_expiry_hours: 24 + session_secret: "${SESSION_SECRET}" # Loaded from env + session_expiry_hours: 24 + cookie_name: "zhealth_session" ai: provider: "gemini" # gemini | openai | anthropic + model: "gemini-3-flash-preview" api_key: "${AI_API_KEY}" ``` diff --git a/PROJECT_MGMT/phased_approach.md b/PROJECT_MGMT/phased_approach.md index 952deda..92240c4 100644 --- a/PROJECT_MGMT/phased_approach.md +++ b/PROJECT_MGMT/phased_approach.md @@ -10,7 +10,7 @@ ## Phase 2: API & Core Auth - API Development with Axum. -- Implementation of Auth logic (JWT, Middleware, RBAC enforcement). +- Implementation of Auth logic (Session-based, Middleware, RBAC enforcement). - Frontend Development (Vite + React + TS) - Initial Layout. ## Phase 3: Integration diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..aa19782 --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "zhealth-backend" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "zhealth" +path = "src/main.rs" + +[dependencies] +# Web Framework +axum = "0.8" +tokio = { version = "1", features = ["full"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "trace"] } + +# Database +sea-orm = { version = "1.1", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" + +# Auth +argon2 = "0.5" +rand = "0.8" + +# Time +chrono = { version = "0.4", features = ["serde"] } + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Error handling +thiserror = "2" +anyhow = "1" + +# Regex for config env expansion +regex = "1" + +# CLI +argh = "0.1" diff --git a/backend/sample.config.yaml b/backend/sample.config.yaml new file mode 100644 index 0000000..ee55c68 --- /dev/null +++ b/backend/sample.config.yaml @@ -0,0 +1,25 @@ +# Sample Configuration for zhealth-backend +# Copy this to config.yaml and update values as needed. +# Secrets should be loaded from environment variables using ${VAR_NAME} syntax. + +server: + host: "127.0.0.1" + port: 3000 + +paths: + database: "./data/zhealth.db" + logs: "./logs" + +logging: + level: "info" # Options: trace | debug | info | warn | error + +auth: + session_secret: "${SESSION_SECRET}" + session_expiry_hours: 24 + cookie_name: "zhealth_session" + cookie_secure: false # Set to true in production with HTTPS + +ai: + provider: "gemini" # Options: gemini | openai | anthropic + model: "gemini-3-flash-preview" + api_key: "${AI_API_KEY}" diff --git a/backend/src/cli.rs b/backend/src/cli.rs new file mode 100644 index 0000000..18062a7 --- /dev/null +++ b/backend/src/cli.rs @@ -0,0 +1,33 @@ +//! CLI definition using argh. + +use argh::FromArgs; + +/// zhealth: AI-powered health management platform +#[derive(FromArgs)] +pub struct Args { + #[argh(subcommand)] + pub command: Command, +} + +#[derive(FromArgs)] +#[argh(subcommand)] +pub enum Command { + Serve(ServeCommand), + Migrate(MigrateCommand), + Version(VersionCommand), +} + +/// Start the server (runs migrations first) +#[derive(FromArgs)] +#[argh(subcommand, name = "serve")] +pub struct ServeCommand {} + +/// Run database migrations only +#[derive(FromArgs)] +#[argh(subcommand, name = "migrate")] +pub struct MigrateCommand {} + +/// Show version information +#[derive(FromArgs)] +#[argh(subcommand, name = "version")] +pub struct VersionCommand {} diff --git a/backend/src/config.rs b/backend/src/config.rs new file mode 100644 index 0000000..842ce6b --- /dev/null +++ b/backend/src/config.rs @@ -0,0 +1,71 @@ +//! Configuration loading and parsing. + +use serde::Deserialize; +use std::fs; +use std::path::Path; + +#[derive(Debug, Deserialize)] +pub struct Config { + pub server: ServerConfig, + pub paths: PathsConfig, + pub logging: LoggingConfig, + pub auth: AuthConfig, + pub ai: AiConfig, +} + +#[derive(Debug, Deserialize)] +pub struct ServerConfig { + pub host: String, + pub port: u16, +} + +#[derive(Debug, Deserialize)] +pub struct PathsConfig { + pub database: String, + pub logs: String, +} + +#[derive(Debug, Deserialize)] +pub struct LoggingConfig { + pub level: String, +} + +#[derive(Debug, Deserialize)] +pub struct AuthConfig { + pub session_secret: String, + pub session_expiry_hours: u32, + pub cookie_name: String, + pub cookie_secure: bool, +} + +#[derive(Debug, Deserialize)] +pub struct AiConfig { + pub provider: String, + pub model: String, + pub api_key: String, +} + +impl Config { + /// Load configuration from a YAML file. + pub fn load>(path: P) -> anyhow::Result { + let content = fs::read_to_string(path)?; + // Expand environment variables in the format ${VAR_NAME} + let expanded = expand_env_vars(&content); + let config: Config = serde_yaml::from_str(&expanded)?; + Ok(config) + } +} + +/// Expand environment variables in the format ${VAR_NAME}. +fn expand_env_vars(content: &str) -> String { + let mut result = content.to_string(); + let re = regex::Regex::new(r"\$\{([^}]+)\}").unwrap(); + + for cap in re.captures_iter(content) { + let var_name = &cap[1]; + let var_value = std::env::var(var_name).unwrap_or_default(); + result = result.replace(&cap[0], &var_value); + } + + result +} diff --git a/backend/src/db.rs b/backend/src/db.rs new file mode 100644 index 0000000..4d6c629 --- /dev/null +++ b/backend/src/db.rs @@ -0,0 +1,42 @@ +//! Database connection and migrations. + +use sea_orm::{ConnectionTrait, Database, DatabaseConnection, DbBackend, DbErr, Schema, Statement}; +use sea_orm::sea_query::SqliteQueryBuilder; + +use crate::config::Config; +use crate::models::bio::{biomarker_entry, biomarker_type}; +use crate::models::user::{session, user}; + +/// Connect to the SQLite database. +pub async fn connect(config: &Config) -> Result { + let db_path = &config.paths.database; + + // Ensure the data directory exists + if let Some(parent) = std::path::Path::new(db_path).parent() { + std::fs::create_dir_all(parent).ok(); + } + + let db_url = format!("sqlite:{}?mode=rwc", db_path); + Database::connect(&db_url).await +} + +/// Run migrations to create tables if they don't exist. +pub async fn run_migrations(db: &DatabaseConnection) -> Result<(), DbErr> { + let schema = Schema::new(DbBackend::Sqlite); + + // Create table statements + let statements = vec![ + schema.create_table_from_entity(user::Entity), + schema.create_table_from_entity(session::Entity), + schema.create_table_from_entity(biomarker_type::Entity), + schema.create_table_from_entity(biomarker_entry::Entity), + ]; + + for mut stmt in statements { + let sql = stmt.if_not_exists().to_string(SqliteQueryBuilder); + db.execute(Statement::from_string(DbBackend::Sqlite, sql)) + .await?; + } + + Ok(()) +} diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..eafe128 --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,75 @@ +mod cli; +mod config; +mod db; +mod models; + +use axum::{routing::get, Router}; +use std::net::SocketAddr; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +use cli::{Args, Command}; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args: Args = argh::from_env(); + + match args.command { + Command::Version(_) => { + println!("zhealth {}", VERSION); + } + Command::Migrate(_) => { + let config = config::Config::load("config.yaml")?; + init_logging(&config); + + tracing::info!("Running migrations..."); + let db = db::connect(&config).await?; + db::run_migrations(&db).await?; + tracing::info!("Migrations complete."); + } + Command::Serve(_) => { + let config = config::Config::load("config.yaml")?; + init_logging(&config); + + // Run migrations first + tracing::info!("Running migrations..."); + let db = db::connect(&config).await?; + db::run_migrations(&db).await?; + tracing::info!("Migrations complete."); + + // Start server + tracing::info!("Starting zhealth-backend..."); + let app = Router::new() + .route("/", get(root)) + .route("/health", get(health_check)); + + let addr: SocketAddr = format!("{}:{}", config.server.host, config.server.port) + .parse() + .expect("Invalid server address"); + + tracing::info!("Listening on http://{}", addr); + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + } + } + + Ok(()) +} + +fn init_logging(config: &config::Config) { + let log_level = config.logging.level.parse().unwrap_or(tracing::Level::INFO); + tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer()) + .with(tracing_subscriber::filter::LevelFilter::from_level(log_level)) + .init(); +} + +async fn root() -> &'static str { + "zhealth API" +} + +async fn health_check() -> &'static str { + "OK" +} diff --git a/backend/src/models/bio/biomarker_entry.rs b/backend/src/models/bio/biomarker_entry.rs new file mode 100644 index 0000000..0ca4969 --- /dev/null +++ b/backend/src/models/bio/biomarker_entry.rs @@ -0,0 +1,56 @@ +//! BiomarkerEntry entity - user-logged biomarker values. + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "biomarker_entries")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + + pub user_id: i32, + pub biomarker_type_id: i32, + + /// The measured value + pub value: f64, + + /// Date when the measurement was taken + pub measured_at: Date, + + #[sea_orm(column_type = "Text", nullable)] + pub notes: Option, + + pub created_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "crate::models::user::user::Entity", + from = "Column::UserId", + to = "crate::models::user::user::Column::Id" + )] + User, + + #[sea_orm( + belongs_to = "super::biomarker_type::Entity", + from = "Column::BiomarkerTypeId", + to = "super::biomarker_type::Column::Id" + )] + BiomarkerType, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::BiomarkerType.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/bio/biomarker_type.rs b/backend/src/models/bio/biomarker_type.rs new file mode 100644 index 0000000..beb09cb --- /dev/null +++ b/backend/src/models/bio/biomarker_type.rs @@ -0,0 +1,43 @@ +//! BiomarkerType entity - the knowledge base of available biomarkers. + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "biomarker_types")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + + #[sea_orm(unique)] + pub name: String, + + /// Category: lipid_panel, metabolic, vitamins, hormones, etc. + pub category: String, + + /// Unit of measurement: mg/dL, mmol/L, ng/mL, etc. + pub unit: String, + + /// Lower bound of normal reference range + pub reference_min: Option, + + /// Upper bound of normal reference range + pub reference_max: Option, + + #[sea_orm(column_type = "Text", nullable)] + pub description: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::biomarker_entry::Entity")] + Entries, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Entries.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/bio/mod.rs b/backend/src/models/bio/mod.rs new file mode 100644 index 0000000..77fed3a --- /dev/null +++ b/backend/src/models/bio/mod.rs @@ -0,0 +1,7 @@ +//! Biomarker entities for health data tracking. + +pub mod biomarker_entry; +pub mod biomarker_type; + +pub use biomarker_entry::Entity as BiomarkerEntry; +pub use biomarker_type::Entity as BiomarkerType; diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs new file mode 100644 index 0000000..32302da --- /dev/null +++ b/backend/src/models/mod.rs @@ -0,0 +1,7 @@ +//! Database models for zhealth. + +pub mod bio; +pub mod user; + +pub use bio::{BiomarkerEntry, BiomarkerType}; +pub use user::{Session, User}; diff --git a/backend/src/models/user/mod.rs b/backend/src/models/user/mod.rs new file mode 100644 index 0000000..3b224a7 --- /dev/null +++ b/backend/src/models/user/mod.rs @@ -0,0 +1,7 @@ +//! User and session entities for authentication. + +pub mod session; +pub mod user; + +pub use session::Entity as Session; +pub use user::Entity as User; diff --git a/backend/src/models/user/session.rs b/backend/src/models/user/session.rs new file mode 100644 index 0000000..676f14e --- /dev/null +++ b/backend/src/models/user/session.rs @@ -0,0 +1,33 @@ +//! Session entity for cookie-based authentication. + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "sessions")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + + pub user_id: i32, + pub expires_at: DateTime, + pub created_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/user/user.rs b/backend/src/models/user/user.rs new file mode 100644 index 0000000..1ac0a19 --- /dev/null +++ b/backend/src/models/user/user.rs @@ -0,0 +1,37 @@ +//! User entity for authentication. + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "users")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + + #[sea_orm(unique)] + pub username: String, + + #[sea_orm(column_type = "Text")] + pub password_hash: String, + + #[sea_orm(unique)] + pub email: String, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::session::Entity")] + Sessions, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Sessions.def() + } +} + +impl ActiveModelBehavior for ActiveModel {}