diff options
| -rw-r--r-- | src/error.rs | 3 | ||||
| -rw-r--r-- | src/handlers.rs | 92 | ||||
| -rw-r--r-- | src/main.rs | 7 | ||||
| -rw-r--r-- | src/models.rs | 47 |
4 files changed, 140 insertions, 9 deletions
diff --git a/src/error.rs b/src/error.rs index 8b4bb6d..d8aaf18 100644 --- a/src/error.rs +++ b/src/error.rs @@ -7,12 +7,14 @@ pub enum AppError { BadRequest, Conflict, Database(sqlx::Error), + NotFound, } 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, + sqlx::Error::RowNotFound => AppError::NotFound, e => AppError::Database(e), } } @@ -27,6 +29,7 @@ impl IntoResponse for AppError { tracing::error!("database error: {e}"); StatusCode::INTERNAL_SERVER_ERROR } + AppError::NotFound => StatusCode::NOT_FOUND, } .into_response() } diff --git a/src/handlers.rs b/src/handlers.rs index 3f815a5..a49d29a 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Query, State}, + extract::{Path, Query, State}, http::StatusCode, response::Json, }; @@ -7,17 +7,36 @@ use chrono::NaiveTime; use sqlx::PgPool; use uuid::Uuid; -use crate::error::AppError; -use crate::models::{DateQuery, MeasurementData, MeasurementView, Unit}; +use crate::models::{AllQuery, DateQuery, MeasurementData, MeasurementView, Unit}; +use crate::{error::AppError, models::MetricType}; pub async fn index() -> &'static str { "Hello World!" } -pub async fn all(State(pool): State<PgPool>) -> Result<Json<Vec<MeasurementView>>, AppError> { +pub async fn all( + State(pool): State<PgPool>, + Query(options): Query<AllQuery>, +) -> Result<Json<Vec<MeasurementView>>, AppError> { + let limit = match options.limit { + Some(x) if (x > 0) => Some(x), + None => None, + _ => return Err(AppError::BadRequest), + }; + // SQL can't bind ASC/DESC as a parameter. The CASE yields measured_at only when + // `reverse` is true (sorted DESC) and NULL otherwise, so that key ties for every + // row and the ASC key decides the order. If this gets slow, use an if/else with a + // separate ASC and DESC query and add an index on measured_at. let rows = sqlx::query_as!( MeasurementView, - "SELECT id, measured_at, metric_type, value, unit FROM measurement" + " + SELECT id, measured_at, metric_type, value, unit FROM measurement + ORDER BY CASE WHEN $2 THEN measured_at END DESC, + measured_at ASC + LIMIT $1 + ", + limit, + options.reverse.unwrap_or(false) ) .fetch_all(&pool) .await?; @@ -92,3 +111,66 @@ pub async fn units(State(pool): State<PgPool>) -> Result<Json<Vec<Unit>>, AppErr .await?, )) } + +pub async fn metric_types(State(pool): State<PgPool>) -> Result<Json<Vec<MetricType>>, AppError> { + Ok(Json( + sqlx::query_scalar("SELECT enum_range(NULL::metric_type)") + .fetch_one(&pool) + .await?, + )) +} + +pub async fn measurement_by_id( + State(pool): State<PgPool>, + Path(id): Path<Uuid>, +) -> Result<Json<MeasurementView>, AppError> { + let measurement = sqlx::query_as!( + MeasurementView, + " + SELECT id, measured_at, metric_type, value, unit FROM measurement + WHERE id = $1 + ", + id + ) + .fetch_one(&pool) + .await?; + Ok(Json(measurement)) +} + +pub async fn delete( + State(pool): State<PgPool>, + Path(id): Path<Uuid>, +) -> Result<StatusCode, AppError> { + sqlx::query!("DELETE FROM measurement WHERE id = $1", id) + .execute(&pool) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +pub async fn update( + State(pool): State<PgPool>, + Path(id): Path<Uuid>, + Json(data): Json<MeasurementData>, +) -> Result<StatusCode, AppError> { + let result = sqlx::query!( + " + UPDATE measurement + SET measured_at = $1, + metric_type = $2, + value = $3, + unit = $4 + WHERE id = $5 + ", + data.measured_at, + data.metric_type, + data.value, + data.unit, + id + ) + .execute(&pool) + .await?; + if result.rows_affected() == 0 { + return Err(AppError::NotFound); + } + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/main.rs b/src/main.rs index acf8896..04f4c99 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ mod models; use axum::{ Router, - routing::{get, post}, + routing::{delete, get, post, put}, }; use dotenvy::dotenv; @@ -19,6 +19,11 @@ async fn main() -> anyhow::Result<()> { .route("/all", get(handlers::all)) .route("/add", post(handlers::add)) .route("/search", get(handlers::search)) + .route("/units", get(handlers::units)) + .route("/metric_types", get(handlers::metric_types)) + .route("/measurement/{id}", get(handlers::measurement_by_id)) + .route("/delete/{id}", delete(handlers::delete)) + .route("/update/{id}", put(handlers::update)) .with_state(pool); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; tracing::info!("listening at 0.0.0.0:3000"); diff --git a/src/models.rs b/src/models.rs index b3f23d1..9e14b71 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,9 +1,10 @@ + use chrono::{DateTime, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; -use strum::EnumIter; +use strum::{EnumIter, IntoStaticStr}; use uuid::Uuid; -#[derive(sqlx::Type, Serialize, Deserialize, EnumIter)] +#[derive(sqlx::Type, Serialize, Deserialize, EnumIter, Debug, IntoStaticStr)] #[sqlx(type_name = "metric_type")] pub enum MetricType { BodyWeight, @@ -11,7 +12,7 @@ pub enum MetricType { Waist, } -#[derive(sqlx::Type, Serialize, Deserialize, EnumIter)] +#[derive(sqlx::Type, Serialize, Deserialize, EnumIter, Debug, IntoStaticStr)] #[sqlx(type_name = "unit")] pub enum Unit { Kilogram, @@ -28,6 +29,7 @@ pub struct MeasurementView { } #[derive(Deserialize)] +#[serde(try_from = "MeasurementDataUnchecked")] pub struct MeasurementData { pub measured_at: DateTime<Utc>, pub metric_type: MetricType, @@ -36,12 +38,51 @@ pub struct MeasurementData { } #[derive(Deserialize)] +struct MeasurementDataUnchecked { + pub measured_at: DateTime<Utc>, + pub metric_type: MetricType, + pub value: f64, + pub unit: Unit, +} + +impl TryFrom<MeasurementDataUnchecked> for MeasurementData { + type Error = String; + + fn try_from(value: MeasurementDataUnchecked) -> Result<Self, Self::Error> { + if value.value < 0.0 || value.value.is_nan() { + return Err("Invalid value".to_string()); + } + match (&value.metric_type, &value.unit) { + (MetricType::BodyWeight, Unit::Kilogram) + | (MetricType::Hip, Unit::Centimeter) + | (MetricType::Waist, Unit::Centimeter) => Ok(Self { + measured_at: value.measured_at, + metric_type: value.metric_type, + value: value.value, + unit: value.unit, + }), + (metric_type, unit) => { + let metric_type: &'static str = metric_type.into(); + let unit: &'static str = unit.into(); + Err(format!("Invalid pair {}, {}", metric_type, unit)) + } + } + } +} + +#[derive(Deserialize)] pub struct DateQuery { pub date: Option<NaiveDate>, pub from: Option<NaiveDate>, pub to: Option<NaiveDate>, } +#[derive(Deserialize)] +pub struct AllQuery { + pub limit: Option<i64>, + pub reverse: Option<bool>, +} + #[cfg(test)] mod tests { use super::*; |
