feat: initialize rust backend with axum server, seaorm models, and project scaffolding
This commit is contained in:
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -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/
|
||||
72
Makefile
Normal file
72
Makefile
Normal file
@@ -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"
|
||||
@@ -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}"
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
43
backend/Cargo.toml
Normal file
43
backend/Cargo.toml
Normal file
@@ -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"
|
||||
25
backend/sample.config.yaml
Normal file
25
backend/sample.config.yaml
Normal file
@@ -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}"
|
||||
33
backend/src/cli.rs
Normal file
33
backend/src/cli.rs
Normal file
@@ -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 {}
|
||||
71
backend/src/config.rs
Normal file
71
backend/src/config.rs
Normal file
@@ -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<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
|
||||
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
|
||||
}
|
||||
42
backend/src/db.rs
Normal file
42
backend/src/db.rs
Normal file
@@ -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<DatabaseConnection, DbErr> {
|
||||
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(())
|
||||
}
|
||||
75
backend/src/main.rs
Normal file
75
backend/src/main.rs
Normal file
@@ -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"
|
||||
}
|
||||
56
backend/src/models/bio/biomarker_entry.rs
Normal file
56
backend/src/models/bio/biomarker_entry.rs
Normal file
@@ -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<String>,
|
||||
|
||||
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<crate::models::user::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::biomarker_type::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::BiomarkerType.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
43
backend/src/models/bio/biomarker_type.rs
Normal file
43
backend/src/models/bio/biomarker_type.rs
Normal file
@@ -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<f64>,
|
||||
|
||||
/// Upper bound of normal reference range
|
||||
pub reference_max: Option<f64>,
|
||||
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::biomarker_entry::Entity")]
|
||||
Entries,
|
||||
}
|
||||
|
||||
impl Related<super::biomarker_entry::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Entries.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
7
backend/src/models/bio/mod.rs
Normal file
7
backend/src/models/bio/mod.rs
Normal file
@@ -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;
|
||||
7
backend/src/models/mod.rs
Normal file
7
backend/src/models/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! Database models for zhealth.
|
||||
|
||||
pub mod bio;
|
||||
pub mod user;
|
||||
|
||||
pub use bio::{BiomarkerEntry, BiomarkerType};
|
||||
pub use user::{Session, User};
|
||||
7
backend/src/models/user/mod.rs
Normal file
7
backend/src/models/user/mod.rs
Normal file
@@ -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;
|
||||
33
backend/src/models/user/session.rs
Normal file
33
backend/src/models/user/session.rs
Normal file
@@ -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<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
37
backend/src/models/user/user.rs
Normal file
37
backend/src/models/user/user.rs
Normal file
@@ -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<super::session::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Sessions.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
Reference in New Issue
Block a user