diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/db.rs | 13 | ||||
| -rw-r--r-- | src/error.rs | 33 | ||||
| -rw-r--r-- | src/handlers.rs | 94 | ||||
| -rw-r--r-- | src/main.rs | 180 | ||||
| -rw-r--r-- | src/models.rs | 76 |
5 files changed, 226 insertions, 170 deletions
diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..87686fa --- /dev/null +++ b/src/db.rs @@ -0,0 +1,13 @@ +use sqlx::{PgPool, postgres::PgPoolOptions}; +use std::env; + +const DATABASE_URL_FALLBACK: &str = "postgres://health:health@127.0.0.1:5432/health"; + +pub async fn connect() -> Result<PgPool, sqlx::Error> { + let database_url = + env::var("DATABASE_URL").unwrap_or_else(|_| DATABASE_URL_FALLBACK.to_owned()); + PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..8b4bb6d --- /dev/null +++ b/src/error.rs @@ -0,0 +1,33 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; + +pub enum AppError { + BadRequest, + Conflict, + Database(sqlx::Error), +} + +impl From<sqlx::Error> for AppError { + fn from(e: sqlx::Error) -> Self { + match e { + sqlx::Error::Database(e) if e.is_unique_violation() => AppError::Conflict, + e => AppError::Database(e), + } + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + match self { + AppError::BadRequest => StatusCode::BAD_REQUEST, + AppError::Conflict => StatusCode::CONFLICT, + AppError::Database(e) => { + tracing::error!("database error: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + } + } + .into_response() + } +} diff --git a/src/handlers.rs b/src/handlers.rs new file mode 100644 index 0000000..3f815a5 --- /dev/null +++ b/src/handlers.rs @@ -0,0 +1,94 @@ +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::Json, +}; +use chrono::NaiveTime; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::{DateQuery, MeasurementData, MeasurementView, Unit}; + +pub async fn index() -> &'static str { + "Hello World!" +} + +pub async fn all(State(pool): State<PgPool>) -> Result<Json<Vec<MeasurementView>>, AppError> { + let rows = sqlx::query_as!( + MeasurementView, + "SELECT id, measured_at, metric_type, value, unit FROM measurement" + ) + .fetch_all(&pool) + .await?; + Ok(Json(rows)) +} + +pub async fn add( + State(pool): State<PgPool>, + Json(data): Json<MeasurementData>, +) -> Result<(StatusCode, Json<Uuid>), AppError> { + let rec = sqlx::query!( + " + INSERT INTO measurement (id, measured_at, metric_type, value, unit) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + ", + Uuid::now_v7(), + data.measured_at, + data.metric_type, + data.value, + data.unit + ) + .fetch_one(&pool) + .await?; + Ok((StatusCode::CREATED, Json(rec.id))) +} + +pub async fn search( + State(pool): State<PgPool>, + Query(date_query): Query<DateQuery>, +) -> Result<Json<Vec<MeasurementView>>, AppError> { + let (from, to) = match (date_query.date, date_query.from, date_query.to) { + (Some(date), None, None) => (Some(date), Some(date)), + (None, from, to) => (from, to), + _ => return Err(AppError::BadRequest), + }; + if from.is_none() && to.is_none() { + return Err(AppError::BadRequest); + } + if let (Some(from), Some(to)) = (from, to) + && from > to + { + return Err(AppError::BadRequest); + } + let start = from.map(|from| from.and_time(NaiveTime::MIN).and_utc()); + let end = match to { + Some(to) => { + let Some(next_day) = to.succ_opt() else { + return Err(AppError::BadRequest); + }; + Some(next_day.and_time(NaiveTime::MIN).and_utc()) + } + None => None, + }; + let rows = sqlx::query_as!( + MeasurementView, + "SELECT id, measured_at, metric_type, value, unit 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?; + Ok(Json(rows)) +} + +pub async fn units(State(pool): State<PgPool>) -> Result<Json<Vec<Unit>>, AppError> { + Ok(Json( + sqlx::query_scalar("SELECT enum_range(NULL::unit)") + .fetch_one(&pool) + .await?, + )) +} diff --git a/src/main.rs b/src/main.rs index 6bf8fa8..acf8896 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,187 +1,27 @@ +mod db; +mod error; +mod handlers; +mod models; + 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<Utc>, - metric_type: MetricType, - value: f64, - unit: Unit, -} - -#[derive(Deserialize)] -pub struct MeasurementData { - measured_at: DateTime<Utc>, - metric_type: MetricType, - value: f64, - unit: Unit, -} - -#[derive(Deserialize)] -pub struct DateQuery { - date: Option<String>, - from: Option<String>, - to: Option<String>, -} #[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 pool = db::connect().await?; let app = Router::new() - .route("/", get(index)) - .route("/all", get(all)) - .route("/add", post(add)) - .route("/search", get(search)) + .route("/", get(handlers::index)) + .route("/all", get(handlers::all)) + .route("/add", post(handlers::add)) + .route("/search", get(handlers::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<PgPool>) -> Result<Json<Vec<MeasurementView>>, 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<PgPool>, Json(row): Json<Value>) -> Response { - if let Ok(insert_row) = from_value::<MeasurementData>(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<PgPool>, - Query(date_query): Query<DateQuery>, -) -> Result<Json<Vec<MeasurementView>>, 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::<NaiveDate>() 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::<NaiveDate>() 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::<NaiveDate>() 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 - }) -} diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..b3f23d1 --- /dev/null +++ b/src/models.rs @@ -0,0 +1,76 @@ +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use strum::EnumIter; +use uuid::Uuid; + +#[derive(sqlx::Type, Serialize, Deserialize, EnumIter)] +#[sqlx(type_name = "metric_type")] +pub enum MetricType { + BodyWeight, + Hip, + Waist, +} + +#[derive(sqlx::Type, Serialize, Deserialize, EnumIter)] +#[sqlx(type_name = "unit")] +pub enum Unit { + Kilogram, + Centimeter, +} + +#[derive(Serialize)] +pub struct MeasurementView { + pub id: Uuid, + pub measured_at: DateTime<Utc>, + pub metric_type: MetricType, + pub value: f64, + pub unit: Unit, +} + +#[derive(Deserialize)] +pub struct MeasurementData { + pub measured_at: DateTime<Utc>, + pub metric_type: MetricType, + pub value: f64, + pub unit: Unit, +} + +#[derive(Deserialize)] +pub struct DateQuery { + pub date: Option<NaiveDate>, + pub from: Option<NaiveDate>, + pub to: Option<NaiveDate>, +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::PgPool; + use strum::IntoEnumIterator; + + #[sqlx::test] + async fn unit_enums_match(pool: PgPool) { + let values: Vec<Unit> = sqlx::query_scalar("SELECT enum_range(NULL::unit)") + .fetch_one(&pool) + .await + .expect("Database has a variant that Rust doesn't"); + assert_eq!( + values.len(), + Unit::iter().count(), + "Rust has a variant that the database doesn't" + ); + } + + #[sqlx::test] + async fn metric_type_enums_match(pool: PgPool) { + let values: Vec<MetricType> = sqlx::query_scalar("SELECT enum_range(NULL::metric_type)") + .fetch_one(&pool) + .await + .expect("Database has a variant that Rust doesn't"); + assert_eq!( + values.len(), + MetricType::iter().count(), + "Rust has a variant that the database doesn't" + ); + } +} |
