diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/error.rs | 47 | ||||
| -rw-r--r-- | src/extract.rs | 15 | ||||
| -rw-r--r-- | src/handlers.rs | 27 | ||||
| -rw-r--r-- | src/main.rs | 5 | ||||
| -rw-r--r-- | src/models.rs | 1 | ||||
| -rw-r--r-- | src/tests.rs | 115 |
6 files changed, 185 insertions, 25 deletions
diff --git a/src/error.rs b/src/error.rs index d8aaf18..232d588 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,13 +1,18 @@ use axum::{ + Json, + extract::rejection::{JsonRejection, PathRejection, QueryRejection}, http::StatusCode, response::{IntoResponse, Response}, }; +use serde_json::json; pub enum AppError { - BadRequest, + BadRequest(&'static str), Conflict, Database(sqlx::Error), + MethodNotAllowed, NotFound, + Rejection(StatusCode, String), } impl From<sqlx::Error> for AppError { @@ -20,17 +25,43 @@ impl From<sqlx::Error> for AppError { } } +impl From<JsonRejection> for AppError { + fn from(rejection: JsonRejection) -> Self { + AppError::Rejection(rejection.status(), rejection.body_text()) + } +} + +impl From<QueryRejection> for AppError { + fn from(rejection: QueryRejection) -> Self { + AppError::Rejection(rejection.status(), rejection.body_text()) + } +} + +impl From<PathRejection> for AppError { + fn from(rejection: PathRejection) -> Self { + AppError::Rejection(rejection.status(), rejection.body_text()) + } +} + impl IntoResponse for AppError { fn into_response(self) -> Response { - match self { - AppError::BadRequest => StatusCode::BAD_REQUEST, - AppError::Conflict => StatusCode::CONFLICT, + let (status, message) = match self { + AppError::BadRequest(message) => (StatusCode::BAD_REQUEST, message.to_owned()), + AppError::Conflict => (StatusCode::CONFLICT, "already exists".to_owned()), AppError::Database(e) => { tracing::error!("database error: {e}"); - StatusCode::INTERNAL_SERVER_ERROR + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal server error".to_owned(), + ) } - AppError::NotFound => StatusCode::NOT_FOUND, - } - .into_response() + AppError::MethodNotAllowed => ( + StatusCode::METHOD_NOT_ALLOWED, + "method not allowed".to_owned(), + ), + AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_owned()), + AppError::Rejection(status, message) => (status, message), + }; + (status, Json(json!({ "error": message }))).into_response() } } diff --git a/src/extract.rs b/src/extract.rs new file mode 100644 index 0000000..d1adffb --- /dev/null +++ b/src/extract.rs @@ -0,0 +1,15 @@ +use axum::extract::{FromRequest, FromRequestParts}; + +use crate::error::AppError; + +#[derive(FromRequest)] +#[from_request(via(axum::Json), rejection(AppError))] +pub struct AppJson<T>(pub T); + +#[derive(FromRequestParts)] +#[from_request(via(axum::extract::Query), rejection(AppError))] +pub struct AppQuery<T>(pub T); + +#[derive(FromRequestParts)] +#[from_request(via(axum::extract::Path), rejection(AppError))] +pub struct AppPath<T>(pub T); diff --git a/src/handlers.rs b/src/handlers.rs index b048681..50ae9e2 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Path, Query, State}, + extract::State, http::{HeaderName, StatusCode, header}, response::Json, }; @@ -9,6 +9,7 @@ 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, }; @@ -19,28 +20,32 @@ pub async fn health() -> StatusCode { pub async fn list_measurements( State(pool): State<PgPool>, - Query(query): Query<ListQuery>, + AppQuery(query): AppQuery<ListQuery>, ) -> Result<Json<Vec<MeasurementView>>, AppError> { let limit = match query.limit { Some(x) if (x > 0) => Some(x), None => None, - _ => return Err(AppError::BadRequest), + _ => return Err(AppError::BadRequest("limit must be positive")), }; let (from, to) = match (query.date, query.from, query.to) { (Some(date), None, None) => (Some(date), Some(date)), (None, from, to) => (from, to), - _ => return Err(AppError::BadRequest), + _ => { + return Err(AppError::BadRequest( + "date can't be combined with from or to", + )); + } }; if let (Some(from), Some(to)) = (from, to) && from > to { - return Err(AppError::BadRequest); + return Err(AppError::BadRequest("from must not be after to")); } 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); + return Err(AppError::BadRequest("to is out of range")); }; Some(next_day.and_time(NaiveTime::MIN).and_utc()) } @@ -72,7 +77,7 @@ pub async fn list_measurements( pub async fn create_measurement( State(pool): State<PgPool>, - Json(data): Json<MeasurementData>, + AppJson(data): AppJson<MeasurementData>, ) -> Result<(StatusCode, [(HeaderName, String); 1], Json<MeasurementView>), AppError> { let measurement = sqlx::query_as!( MeasurementView, @@ -99,7 +104,7 @@ pub async fn create_measurement( pub async fn get_measurement( State(pool): State<PgPool>, - Path(id): Path<Uuid>, + AppPath(id): AppPath<Uuid>, ) -> Result<Json<MeasurementView>, AppError> { let measurement = sqlx::query_as!( MeasurementView, @@ -116,8 +121,8 @@ pub async fn get_measurement( pub async fn update_measurement( State(pool): State<PgPool>, - Path(id): Path<Uuid>, - Json(data): Json<MeasurementData>, + AppPath(id): AppPath<Uuid>, + AppJson(data): AppJson<MeasurementData>, ) -> Result<StatusCode, AppError> { let result = sqlx::query!( " @@ -144,7 +149,7 @@ pub async fn update_measurement( pub async fn delete_measurement( State(pool): State<PgPool>, - Path(id): Path<Uuid>, + AppPath(id): AppPath<Uuid>, ) -> Result<StatusCode, AppError> { sqlx::query!("DELETE FROM measurement WHERE id = $1", id) .execute(&pool) diff --git a/src/main.rs b/src/main.rs index 3faa650..ee7206c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod db; mod error; +mod extract; mod handlers; mod models; #[cfg(test)] @@ -9,6 +10,8 @@ use axum::{Router, routing::get}; use dotenvy::dotenv; use sqlx::PgPool; +use crate::error::AppError; + fn router(pool: PgPool) -> Router { Router::new() .route("/health", get(handlers::health)) @@ -23,6 +26,8 @@ fn router(pool: PgPool) -> Router { .delete(handlers::delete_measurement), ) .route("/metric-types", get(handlers::metric_types)) + .fallback(async || AppError::NotFound) + .method_not_allowed_fallback(async || AppError::MethodNotAllowed) .with_state(pool) } diff --git a/src/models.rs b/src/models.rs index 6b44040..7096c64 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,4 +1,3 @@ - use chrono::{DateTime, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; use strum::{EnumIter, IntoStaticStr}; diff --git a/src/tests.rs b/src/tests.rs index 586f35a..5b21352 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -116,7 +116,10 @@ async fn create_returns_the_measurement_and_location(pool: PgPool) { async fn create_rejects_bad_bodies(pool: PgPool) { let app = router(pool); let cases = [ - (json!({"metric_type": "Hip"}).to_string(), StatusCode::UNPROCESSABLE_ENTITY), + ( + json!({"metric_type": "Hip"}).to_string(), + StatusCode::UNPROCESSABLE_ENTITY, + ), ( measurement("2026-10-14T12:00:00Z", "Nope", 1.0, "Kilogram"), StatusCode::UNPROCESSABLE_ENTITY, @@ -219,8 +222,14 @@ async fn list_ranges_are_inclusive_of_both_days(pool: PgPool) { let cases = [ ("/measurements?from=2026-10-15", vec![2.0, 3.0, 4.0]), ("/measurements?to=2026-10-14", vec![1.0]), - ("/measurements?from=2026-10-14&to=2026-10-15", vec![1.0, 2.0, 3.0]), - ("/measurements?from=2026-10-15&to=2026-10-15", vec![2.0, 3.0]), + ( + "/measurements?from=2026-10-14&to=2026-10-15", + vec![1.0, 2.0, 3.0], + ), + ( + "/measurements?from=2026-10-15&to=2026-10-15", + vec![2.0, 3.0], + ), ("/measurements?from=2030-01-01", vec![]), ]; for (uri, expected) in cases { @@ -236,7 +245,10 @@ async fn list_combines_filters_with_order_and_limit(pool: PgPool) { seed(&app).await; let cases = [ ("/measurements?from=2026-10-15&limit=1", vec![2.0]), - ("/measurements?from=2026-10-15&order=desc&limit=2", vec![4.0, 3.0]), + ( + "/measurements?from=2026-10-15&order=desc&limit=2", + vec![4.0, 3.0], + ), ("/measurements?date=2026-10-15&order=desc", vec![3.0, 2.0]), ]; for (uri, expected) in cases { @@ -350,8 +362,101 @@ async fn delete_is_idempotent(pool: PgPool) { #[sqlx::test] async fn old_paths_are_gone(pool: PgPool) { let app = router(pool); - for uri in ["/", "/all", "/search?from=2026-10-14", "/units", "/metric_types"] { + for uri in [ + "/", + "/all", + "/search?from=2026-10-14", + "/units", + "/metric_types", + ] { let (status, _) = get_json(&app, uri).await; assert_eq!(status, StatusCode::NOT_FOUND, "{uri}"); } } + +#[sqlx::test] +async fn errors_have_a_json_body(pool: PgPool) { + let app = router(pool); + let missing = format!("/measurements/{}", Uuid::now_v7()); + let valid = measurement("2026-10-14T12:00:00Z", "Waist", 80.0, "Centimeter"); + let invalid_pair = measurement("2026-10-14T12:00:00Z", "Waist", 80.0, "Kilogram"); + let cases = [ + ( + Method::GET, + "/measurements?limit=0".to_owned(), + None, + StatusCode::BAD_REQUEST, + ), + ( + Method::GET, + "/measurements?order=sideways".to_owned(), + None, + StatusCode::BAD_REQUEST, + ), + ( + Method::GET, + "/measurements?date=bad".to_owned(), + None, + StatusCode::BAD_REQUEST, + ), + ( + Method::GET, + "/measurements/not-a-uuid".to_owned(), + None, + StatusCode::BAD_REQUEST, + ), + (Method::GET, missing.clone(), None, StatusCode::NOT_FOUND), + (Method::PUT, missing, Some(valid), StatusCode::NOT_FOUND), + ( + Method::POST, + "/measurements".to_owned(), + Some("not json".to_owned()), + StatusCode::BAD_REQUEST, + ), + ( + Method::POST, + "/measurements".to_owned(), + Some(invalid_pair), + StatusCode::UNPROCESSABLE_ENTITY, + ), + (Method::GET, "/nope".to_owned(), None, StatusCode::NOT_FOUND), + ( + Method::PATCH, + "/measurements".to_owned(), + None, + StatusCode::METHOD_NOT_ALLOWED, + ), + ]; + for (method, uri, body, expected) in cases { + let (status, _, body) = send(&app, method.clone(), &uri, body).await; + assert_eq!(status, expected, "{method} {uri}"); + assert!(body["error"].is_string(), "{method} {uri}: {body}"); + } +} + +#[sqlx::test] +async fn error_messages_explain_the_problem(pool: PgPool) { + let app = router(pool); + let cases = [ + ("/measurements?limit=0", "limit must be positive"), + ( + "/measurements?date=2026-10-15&from=2026-10-14", + "date can't be combined with from or to", + ), + ( + "/measurements?from=2026-10-16&to=2026-10-14", + "from must not be after to", + ), + ]; + for (uri, message) in cases { + let (_, body) = get_json(&app, uri).await; + assert_eq!(body["error"], message, "{uri}"); + } + let body = measurement("2026-10-14T12:00:00Z", "Waist", 80.0, "Kilogram"); + let (_, _, body) = send(&app, Method::POST, "/measurements", Some(body)).await; + let message = body["error"].as_str().unwrap(); + assert!( + message.contains("Invalid pair Waist, Kilogram"), + "{message}" + ); +} |
