summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/error.rs3
-rw-r--r--src/handlers.rs92
-rw-r--r--src/main.rs7
-rw-r--r--src/models.rs47
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::*;