diff options
| -rw-r--r-- | Cargo.lock | 22 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | src/error.rs | 14 | ||||
| -rw-r--r-- | src/handlers.rs | 38 | ||||
| -rw-r--r-- | src/main.rs | 1 | ||||
| -rw-r--r-- | src/tests.rs | 114 |
6 files changed, 181 insertions, 9 deletions
@@ -308,6 +308,27 @@ dependencies = [ ] [[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] name = "ctutils" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -593,6 +614,7 @@ dependencies = [ "anyhow", "axum", "chrono", + "csv", "dotenvy", "http-body-util", "serde", @@ -16,6 +16,7 @@ serde_json = "1.0" uuid = { version = "1.26.0", features = ["atomic", "v7", "serde"] } chrono = { version = "0.4", features = ["serde"] } strum = { version = "0.28.0", features = ["derive"] } +csv = "1" [dev-dependencies] tower = { version = "0.5", features = ["util"] } diff --git a/src/error.rs b/src/error.rs index 232d588..d16106a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -9,6 +9,7 @@ use serde_json::json; pub enum AppError { BadRequest(&'static str), Conflict, + Csv(csv::Error), Database(sqlx::Error), MethodNotAllowed, NotFound, @@ -25,6 +26,12 @@ impl From<sqlx::Error> for AppError { } } +impl From<csv::Error> for AppError { + fn from(e: csv::Error) -> Self { + AppError::Csv(e) + } +} + impl From<JsonRejection> for AppError { fn from(rejection: JsonRejection) -> Self { AppError::Rejection(rejection.status(), rejection.body_text()) @@ -48,6 +55,13 @@ impl IntoResponse for AppError { let (status, message) = match self { AppError::BadRequest(message) => (StatusCode::BAD_REQUEST, message.to_owned()), AppError::Conflict => (StatusCode::CONFLICT, "already exists".to_owned()), + AppError::Csv(e) => { + tracing::error!("csv error: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal server error".to_owned(), + ) + } AppError::Database(e) => { tracing::error!("database error: {e}"); ( diff --git a/src/handlers.rs b/src/handlers.rs index 5d31600..7067f3a 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -21,6 +21,40 @@ pub async fn list_measurements( State(pool): State<PgPool>, AppQuery(query): AppQuery<ListQuery>, ) -> Result<Json<Vec<MeasurementView>>, AppError> { + Ok(Json(fetch_measurements(&pool, &query).await?)) +} + +pub async fn export_measurements( + State(pool): State<PgPool>, + AppQuery(query): AppQuery<ListQuery>, +) -> Result<([(HeaderName, &'static str); 2], Vec<u8>), 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<Vec<MeasurementView>, AppError> { let limit = match query.limit { Some(x) if (x > 0) => Some(x), None => None, @@ -52,9 +86,9 @@ pub async fn list_measurements( matches!(query.order, Order::Desc), query.metric_type ) - .fetch_all(&pool) + .fetch_all(pool) .await?; - Ok(Json(rows)) + Ok(rows) } pub async fn create_measurement( diff --git a/src/main.rs b/src/main.rs index ee7206c..a80a12f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ fn router(pool: PgPool) -> Router { "/measurements", get(handlers::list_measurements).post(handlers::create_measurement), ) + .route("/measurements/export", get(handlers::export_measurements)) .route( "/measurements/{id}", get(handlers::get_measurement) diff --git a/src/tests.rs b/src/tests.rs index e97065a..9102975 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,9 +1,9 @@ use axum::{ Router, - body::Body, + body::{Body, Bytes}, http::{ HeaderMap, Method, Request, StatusCode, - header::{CONTENT_TYPE, LOCATION}, + header::{CONTENT_DISPOSITION, CONTENT_TYPE, LOCATION}, }, }; use chrono::{TimeDelta, Utc}; @@ -21,6 +21,25 @@ async fn send( uri: &str, body: Option<String>, ) -> (StatusCode, HeaderMap, Value) { + let (status, headers, bytes) = send_raw(app, method, uri, body).await; + ( + status, + headers, + serde_json::from_slice(&bytes).unwrap_or(Value::Null), + ) +} + +async fn get_text(app: &Router, uri: &str) -> (StatusCode, HeaderMap, String) { + let (status, headers, bytes) = send_raw(app, Method::GET, uri, None).await; + (status, headers, String::from_utf8(bytes.to_vec()).unwrap()) +} + +async fn send_raw( + app: &Router, + method: Method, + uri: &str, + body: Option<String>, +) -> (StatusCode, HeaderMap, Bytes) { let mut request = Request::builder().method(method).uri(uri); let body = match body { Some(body) => { @@ -37,11 +56,7 @@ async fn send( let status = response.status(); let headers = response.headers().clone(); let bytes = response.into_body().collect().await.unwrap().to_bytes(); - ( - status, - headers, - serde_json::from_slice(&bytes).unwrap_or(Value::Null), - ) + (status, headers, bytes) } async fn get_json(app: &Router, uri: &str) -> (StatusCode, Value) { @@ -564,3 +579,88 @@ async fn update_rejects_future_measured_at(pool: PgPool) { let (_, row) = get_json(&app, &uri).await; assert_eq!(row["value"], json!(80.0)); } + +#[sqlx::test] +async fn export_returns_a_csv_download(pool: PgPool) { + let app = router(pool); + let id = add_waist(&app, "2025-10-14T12:00:00Z", 80.5).await; + let (status, headers, csv) = get_text(&app, "/measurements/export").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(headers[CONTENT_TYPE], "text/csv; charset=utf-8"); + assert_eq!( + headers[CONTENT_DISPOSITION], + "attachment; filename=\"measurements.csv\"" + ); + assert_eq!( + csv, + format!( + "id,measured_at,metric_type,value,unit\n{id},2025-10-14T12:00:00Z,Waist,80.5,Centimeter\n" + ) + ); +} + +#[sqlx::test] +async fn export_without_data_is_just_the_header(pool: PgPool) { + let app = router(pool); + let (status, _, csv) = get_text(&app, "/measurements/export").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(csv, "id,measured_at,metric_type,value,unit\n"); +} + +#[sqlx::test] +async fn export_matches_the_list_for_the_same_query(pool: PgPool) { + let app = router(pool); + let rows = [ + ("2025-10-14T12:00:00Z", "BodyWeight", 70.0, "Kilogram"), + ("2025-10-15T12:00:00Z", "Waist", 80.0, "Centimeter"), + ("2025-10-16T12:00:00Z", "BodyWeight", 71.0, "Kilogram"), + ("2025-10-17T12:00:00Z", "Hip", 95.0, "Centimeter"), + ]; + for (measured_at, metric_type, value, unit) in rows { + let body = measurement(measured_at, metric_type, value, unit); + let (status, _, _) = send(&app, Method::POST, "/measurements", Some(body)).await; + assert_eq!(status, StatusCode::CREATED); + } + let queries = [ + "", + "?metric_type=BodyWeight", + "?order=desc&limit=2", + "?from=2025-10-15T00:00:00Z&to=2025-10-17T00:00:00Z", + "?metric_type=Hip&from=2026-01-01T00:00:00Z", + ]; + for query in queries { + let (_, listed) = get_json(&app, &format!("/measurements{query}")).await; + let listed: Vec<&str> = listed + .as_array() + .unwrap() + .iter() + .map(|row| row["id"].as_str().unwrap()) + .collect(); + let (status, _, csv) = get_text(&app, &format!("/measurements/export{query}")).await; + assert_eq!(status, StatusCode::OK, "{query}"); + let exported: Vec<&str> = csv + .lines() + .skip(1) + .map(|line| line.split(',').next().unwrap()) + .collect(); + assert_eq!(exported, listed, "{query}"); + } +} + +#[sqlx::test] +async fn export_rejects_bad_queries_with_json_errors(pool: PgPool) { + let app = router(pool); + let uris = [ + "/measurements/export?limit=0", + "/measurements/export?metric_type=Nope", + "/measurements/export?date=2025-10-15", + "/measurements/export?from=2025-10-16T00:00:00Z&to=2025-10-14T00:00:00Z", + ]; + for uri in uris { + let (status, _, body) = send(&app, Method::GET, uri, None).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{uri}"); + assert!(body["error"].is_string(), "{uri}: {body}"); + } + let (status, _, _) = send(&app, Method::POST, "/measurements/export", None).await; + assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED); +} |
