summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorbatsumaru <>2026-10-16 15:17:17 +0900
committerbatsumaru <>2026-10-16 15:17:17 +0900
commitb49bb08c7a77fe4b29cf4ed0504c2f1c32a025e3 (patch)
treeea763ca8cb858768e8ac8179f0bd6e32978541b7
parentad9a134daec1f73940e8deaeb6c2a481ce9d6520 (diff)
Update error responses
-rw-r--r--src/error.rs47
-rw-r--r--src/extract.rs15
-rw-r--r--src/handlers.rs27
-rw-r--r--src/main.rs5
-rw-r--r--src/models.rs1
-rw-r--r--src/tests.rs115
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}"
+ );
+}