summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock22
-rw-r--r--Cargo.toml1
-rw-r--r--src/error.rs14
-rw-r--r--src/handlers.rs38
-rw-r--r--src/main.rs1
-rw-r--r--src/tests.rs114
6 files changed, 181 insertions, 9 deletions
diff --git a/Cargo.lock b/Cargo.lock
index c3010f8..6dc9fd0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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",
diff --git a/Cargo.toml b/Cargo.toml
index 48e90fe..f9da302 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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);
+}