use axum::{ extract::State, http::{HeaderName, StatusCode, header}, response::Json, }; use sqlx::PgPool; use strum::IntoEnumIterator; use uuid::Uuid; use crate::error::AppError; use crate::extract::{AppJson, AppPath, AppQuery}; use crate::models::{ ListQuery, MeasurementData, MeasurementView, MetricType, MetricUnitPair, Order, Unit, }; pub async fn health() -> StatusCode { StatusCode::OK } pub async fn list_measurements( State(pool): State, AppQuery(query): AppQuery, ) -> Result>, AppError> { Ok(Json(fetch_measurements(&pool, &query).await?)) } pub async fn export_measurements( State(pool): State, AppQuery(query): AppQuery, ) -> Result<([(HeaderName, &'static str); 2], Vec), AppError> { let rows = fetch_measurements(&pool, &query).await?; let mut writer = csv::WriterBuilder::new() .has_headers(false) .from_writer(Vec::new()); writer.write_record(["id", "measured_at", "metric_type", "value", "unit"])?; for row in &rows { writer.serialize(row)?; } let csv = writer .into_inner() .map_err(|e| csv::Error::from(e.into_error()))?; Ok(( [ (header::CONTENT_TYPE, "text/csv; charset=utf-8"), ( header::CONTENT_DISPOSITION, "attachment; filename=\"measurements.csv\"", ), ], csv, )) } async fn fetch_measurements( pool: &PgPool, query: &ListQuery, ) -> Result, AppError> { let limit = match query.limit { Some(x) if (x > 0) => Some(x), None => None, _ => return Err(AppError::BadRequest("limit must be positive")), }; if let (Some(from), Some(to)) = (query.from, query.to) && from > to { return Err(AppError::BadRequest("from must not be after to")); } // SQL can't bind ASC/DESC as a parameter. The CASE yields measured_at only when // `order` is desc (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 WHERE ($1::timestamptz IS NULL OR measured_at >= $1) AND ($2::timestamptz IS NULL OR measured_at < $2) AND ($5::metric_type IS NULL OR metric_type = $5) ORDER BY CASE WHEN $4 THEN measured_at END DESC, measured_at ASC LIMIT $3 ", query.from, query.to, limit, matches!(query.order, Order::Desc), query.metric_type ) .fetch_all(pool) .await?; Ok(rows) } pub async fn create_measurement( State(pool): State, AppJson(data): AppJson, ) -> Result<(StatusCode, [(HeaderName, String); 1], Json), AppError> { let measurement = sqlx::query_as!( MeasurementView, " INSERT INTO measurement (id, measured_at, metric_type, value, unit) VALUES ($1, $2, $3, $4, $5) RETURNING id, measured_at, metric_type, value, unit ", Uuid::now_v7(), data.measured_at, data.metric_type, data.value, data.unit ) .fetch_one(&pool) .await?; let location = format!("/measurements/{}", measurement.id); Ok(( StatusCode::CREATED, [(header::LOCATION, location)], Json(measurement), )) } pub async fn get_measurement( State(pool): State, AppPath(id): AppPath, ) -> Result, 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 update_measurement( State(pool): State, AppPath(id): AppPath, AppJson(data): AppJson, ) -> Result { 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) } pub async fn delete_measurement( State(pool): State, AppPath(id): AppPath, ) -> Result { sqlx::query!("DELETE FROM measurement WHERE id = $1", id) .execute(&pool) .await?; Ok(StatusCode::NO_CONTENT) } pub async fn metric_types() -> Json> { Json( MetricType::iter() .flat_map(|metric_type| { Unit::iter() .filter(move |unit| metric_type.accepts(unit)) .map(move |unit| MetricUnitPair { metric_type, unit }) }) .collect(), ) }