diff options
| author | batsumaru <> | 2026-10-16 11:57:17 +0900 |
|---|---|---|
| committer | batsumaru <> | 2026-10-16 11:57:17 +0900 |
| commit | a69e78b7482227f22e7ddbfaa7dc7ee31f29a99b (patch) | |
| tree | e829a766484e1937bcbcd4e97668e9d674ea6099 | |
| parent | 7dc5038a7438d0eaf63d4bf65f88f6f5ab6b3207 (diff) | |
Add enum fetchers
| -rw-r--r-- | Cargo.lock | 22 | ||||
| -rw-r--r-- | Cargo.toml | 3 | ||||
| -rw-r--r-- | sqlx.toml | 5 | ||||
| -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 |
8 files changed, 253 insertions, 173 deletions
@@ -597,6 +597,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "strum", "tokio", "tracing", "tracing-subscriber", @@ -1552,6 +1553,27 @@ dependencies = [ ] [[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -14,4 +14,5 @@ dotenvy = "0.15" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" uuid = { version = "1.26.0", features = ["atomic", "v7", "serde"] } -chrono = { version = "0.4", features = ["serde"] }
\ No newline at end of file +chrono = { version = "0.4", features = ["serde"] } +strum = { version = "0.28.0", features = ["derive"] } @@ -1,3 +1,4 @@ [macros.type-overrides] -'metric_type' = "crate::MetricType" -'unit' = "crate::Unit"
\ No newline at end of file +'metric_type' = "crate::models::MetricType" +'unit' = "crate::models::Unit" +'unit[]' = "Vec<crate::models::Unit>" 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" + ); + } +} |
