diff options
| author | batsumaru <> | 2026-10-16 15:47:04 +0900 |
|---|---|---|
| committer | batsumaru <> | 2026-10-16 15:47:04 +0900 |
| commit | 67a57fa6221ed9bc6920a881784b308d4f5c3e6e (patch) | |
| tree | c37dbb4725c358f51fbdbd8d08dd67b8c6f5f8fb /src | |
| parent | 0033269fb879cab7e6227d0fd601f18402effe00 (diff) | |
Add csv export
Diffstat (limited to 'src')
| -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 |
4 files changed, 158 insertions, 9 deletions
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); +} |
