summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/db.rs13
-rw-r--r--src/error.rs33
-rw-r--r--src/handlers.rs94
-rw-r--r--src/main.rs180
-rw-r--r--src/models.rs76
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"
+ );
+ }
+}