use axum::{ Router, extract::{Query, State}, http::StatusCode, response::{IntoResponse, Json, Response}, routing::{get, post}, }; use chrono::{DateTime, NaiveDate, Utc}; use dotenvy::dotenv; use serde::{Deserialize, Serialize}; use serde_json::{Value, from_value}; use sqlx::{PgPool, postgres::PgPoolOptions}; use std::env; use uuid::Uuid; const DATABASE_URL_FALLBACK: &str = "postgres://health:health@127.0.0.1:5432/health"; #[derive(sqlx::Type, Serialize, Deserialize)] #[sqlx(type_name = "metric_type")] pub enum MetricType { BodyWeight, Hip, Waist, } #[derive(sqlx::Type, Serialize, Deserialize)] #[sqlx(type_name = "unit")] pub enum Unit { Kilogram, Centimeter, } #[derive(Serialize)] pub struct MeasurementView { id: Uuid, measured_at: DateTime, metric_type: MetricType, value: f64, unit: Unit, } #[derive(Deserialize)] pub struct MeasurementData { measured_at: DateTime, metric_type: MetricType, value: f64, unit: Unit, } #[derive(Deserialize)] pub struct DateQuery { date: Option, from: Option, to: Option, } #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt::init(); dotenv().ok(); let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| DATABASE_URL_FALLBACK.to_owned()); let pool = PgPoolOptions::new() .max_connections(5) .connect(&database_url) .await?; let app = Router::new() .route("/", get(index)) .route("/all", get(all)) .route("/add", post(add)) .route("/search", get(search)) .with_state(pool); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; tracing::info!("listening at 0.0.0.0:3000"); axum::serve(listener, app).await?; Ok(()) } async fn index() -> &'static str { "Hello World!" } async fn all(State(pool): State) -> Result>, StatusCode> { sqlx::query_as!(MeasurementView, "SELECT * FROM measurement") .fetch_all(&pool) .await .map(Json) .map_err(|e| { tracing::error!("failed to fetch measurements: {e}"); StatusCode::INTERNAL_SERVER_ERROR }) } async fn add(State(pool): State, Json(row): Json) -> Response { if let Ok(insert_row) = from_value::(row) { match sqlx::query!( " INSERT INTO measurement (id, measured_at, metric_type, value, unit) VALUES ($1, $2, $3, $4, $5) RETURNING id ", Uuid::now_v7(), insert_row.measured_at, insert_row.metric_type, insert_row.value, insert_row.unit ) .fetch_one(&pool) .await { Ok(rec) => (StatusCode::CREATED, Json(rec.id)).into_response(), Err(sqlx::Error::Database(e)) if e.is_unique_violation() => { StatusCode::CONFLICT.into_response() } Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), } } else { StatusCode::UNPROCESSABLE_ENTITY.into_response() } } async fn search( State(pool): State, Query(date_query): Query, ) -> Result>, StatusCode> { if date_query.date.is_some() && (date_query.from.is_some() || date_query.to.is_some()) { return Err(StatusCode::BAD_REQUEST); } let (start, end) = if let Some(date) = &date_query.date { let Ok(date) = date.parse::() else { return Err(StatusCode::BAD_REQUEST); }; let Some(next_day) = date.succ_opt() else { return Err(StatusCode::BAD_REQUEST); }; ( Some(date.and_hms_opt(0, 0, 0).unwrap().and_utc()), Some(next_day.and_hms_opt(0, 0, 0).unwrap().and_utc()), ) } else { let start = match &date_query.from { Some(from) => { let Ok(from) = from.parse::() else { return Err(StatusCode::BAD_REQUEST); }; Some(from.and_hms_opt(0, 0, 0).unwrap().and_utc()) } None => None, }; let end = match &date_query.to { Some(to) => { let Ok(to) = to.parse::() else { return Err(StatusCode::BAD_REQUEST); }; let Some(next_day) = to.succ_opt() else { return Err(StatusCode::BAD_REQUEST); }; Some(next_day.and_hms_opt(0, 0, 0).unwrap().and_utc()) } None => None, }; (start, end) }; if start.is_none() && end.is_none() { return Err(StatusCode::BAD_REQUEST); } if let (Some(start), Some(end)) = (start, end) && start >= end { return Err(StatusCode::BAD_REQUEST); } sqlx::query_as!( MeasurementView, "SELECT * FROM measurement WHERE ($1::timestamptz IS NULL OR measured_at >= $1) AND ($2::timestamptz IS NULL OR measured_at < $2)", start, end ) .fetch_all(&pool) .await .map(Json) .map_err(|e| { tracing::error!("failed to fetch measurements: {e}"); StatusCode::INTERNAL_SERVER_ERROR }) }