use axum::extract::{Json, Path, State}; use axum::http::StatusCode; use axum::routing::{delete, get}; use axum::Router; use futures::TryStreamExt; use sqlx::sqlite::SqlitePool; use crate::place::Place; type Result = std::result::Result; fn internal_error(err: E) -> (StatusCode, String) where E: std::error::Error, { (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) } async fn get_places(State(pool): State) -> Result>> { let places = sqlx::query!( r#"SELECT id, name, address, open_hours, icon, description, url, longitude as "longitude: f64", latitude as "latitude: f64" FROM places WHERE active = TRUE"# ) .fetch(&pool) .map_ok(|p| Place { id: Some(p.id), name: p.name, address: p.address, open_hours: p.open_hours, icon: p.icon, description: p.description, latitude: p.latitude, longitude: p.longitude, url: p.url, }) .try_collect::>() .await .map_err(internal_error)?; Ok(Json(places)) } async fn upsert_place( State(pool): State, Json(place): Json, ) -> Result> { if place.id.is_some() { update_place(pool, place).await } else { insert_place(pool, place).await } } async fn insert_place(pool: SqlitePool, mut place: Place) -> Result> { let id = sqlx::query_scalar!( r#"INSERT INTO places (name, address, open_hours, icon, description, longitude, latitude, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id"#, place.name, place.address, place.open_hours, place.icon, place.description, place.longitude, place.latitude, place.url ) .fetch_one(&pool) .await .map_err(internal_error)?; place.id = Some(id); Ok(Json(place)) } async fn update_place(pool: SqlitePool, place: Place) -> Result> { let result = sqlx::query!( r#"UPDATE places SET (name, address, open_hours, icon, description, longitude, latitude, url) = (?, ?, ?, ?, ?, ?, ?, ?) WHERE id = ?"#, place.name, place.address, place.open_hours, place.icon, place.description, place.longitude, place.latitude, place.url, place.id, ) .execute(&pool) .await .map_err(internal_error)?; if result.rows_affected() == 1 { Ok(Json(place)) } else { Err((StatusCode::NOT_FOUND, "".to_owned())) } } async fn delete_place(State(pool): State, Path(id): Path) -> Result<()> { let result = ::sqlx::query!("UPDATE places SET active = FALSE WHERE id = ?", id) .execute(&pool) .await .map_err(internal_error)?; if result.rows_affected() == 1 { Ok(()) } else { Err((StatusCode::NOT_FOUND, "".to_owned())) } } pub fn places_routes(pool: SqlitePool) -> Router { Router::new() .route("/", get(get_places).put(upsert_place)) .route("/:id", delete(delete_place)) .with_state(pool) } mod tests { #![cfg(test)] use super::places_routes; use crate::place::Place; use anyhow::{Context, Result}; use axum::http::StatusCode; use axum::Router; use axum_test::TestServer; use sqlx::sqlite::SqlitePool; fn server(pool: &SqlitePool) -> Result { let router = Router::new().nest("/places", places_routes(pool.clone())); TestServer::new(router) } async fn get_from_db(pool: &SqlitePool, id: i64) -> Result { sqlx::query_as!( Place, r#"SELECT id, name, address, open_hours, icon, description, url, longitude as "longitude: f64", latitude as "latitude: f64" FROM places WHERE active = ?"#, id ) .fetch_one(pool) .await .context("Couldn't get from DB") } #[sqlx::test] async fn test_add_place(pool: SqlitePool) -> Result<()> { let server = server(&pool)?; let mut place = Place { id: None, name: "Sherlock Holmes".to_owned(), address: "221 B Baker Street, London".to_owned(), description: "Museum and Gift Shop".to_owned(), icon: "museum".to_owned(), latitude: 51.5237669, longitude: -0.1627829, open_hours: "Tu-Su 09:30-18:00".to_owned(), url: Some("https://www.sherlock-holmes.co.uk/".to_owned()), }; // Insert the place let res = server.put("/places").json(&place).await; // We should get a success on the request assert_eq!(res.status_code(), StatusCode::OK); let res_place: Place = res.json(); // The inserted place should have an ID assert!(res_place.id.is_some()); // Add the returned ID to the original place place.id = res_place.id; // And now they should be equal assert_eq!(place, res_place); // Check against the place stored in the DB let db_place = get_from_db(&pool, place.id.unwrap()).await?; assert_eq!(place, db_place); Ok(()) } #[sqlx::test] async fn test_get_places(pool: SqlitePool) -> Result<()> { let server = server(&pool)?; let mut places = vec![ Place { id: None, name: "Sherlock Holmes".to_owned(), address: "221 B Baker Street, London".to_owned(), description: "Museum and Gift Shop".to_owned(), icon: "museum".to_owned(), latitude: 51.5237669, longitude: -0.1627829, open_hours: "Tu-Su 09:30-18:00".to_owned(), url: Some("https://www.sherlock-holmes.co.uk/".to_owned()), }, Place { id: None, name: "Museo Nacional de Historia Natural".to_owned(), address: "Parque Quinta Normal S/N, Santiago".to_owned(), description: "Museo".to_owned(), icon: "museum".to_owned(), latitude: -70.681838888889, longitude: -33.4421694444449, open_hours: "Tu-Su 10:00-18:00".to_owned(), url: Some("https://www.mnhn.gob.cl/".to_owned()), }, ]; // insert the places for p in &mut places { let res = server.put("/places").json(&p).await; // We should get a success on the request assert_eq!(res.status_code(), StatusCode::OK); let res_place: Place = res.json(); // The inserted place should have an ID assert!(res_place.id.is_some()); // Add the returned ID to the original place p.id = res_place.id; } // and fetch them let res = server.get("/places").await; // We should get a success on the request assert_eq!(res.status_code(), StatusCode::OK); let mut res_places: Vec = res.json(); // and they should be equal places.sort_by(|a, b| a.id.cmp(&b.id)); res_places.sort_by(|a, b| a.id.cmp(&b.id)); assert_eq!(places, res_places); Ok(()) } #[sqlx::test] async fn test_delete(pool: SqlitePool) -> Result<()> { let server = server(&pool)?; let mut places = vec![ Place { id: None, name: "Sherlock Holmes".to_owned(), address: "221 B Baker Street, London".to_owned(), description: "Museum and Gift Shop".to_owned(), icon: "museum".to_owned(), latitude: 51.5237669, longitude: -0.1627829, open_hours: "Tu-Su 09:30-18:00".to_owned(), url: Some("https://www.sherlock-holmes.co.uk/".to_owned()), }, Place { id: None, name: "Museo Nacional de Historia Natural".to_owned(), address: "Parque Quinta Normal S/N, Santiago".to_owned(), description: "Museo".to_owned(), icon: "museum".to_owned(), latitude: -70.681838888889, longitude: -33.4421694444449, open_hours: "Tu-Su 10:00-18:00".to_owned(), url: Some("https://www.mnhn.gob.cl/".to_owned()), }, ]; // insert the places for p in &mut places { let res = server.put("/places").json(&p).await; // We should get a success on the request assert_eq!(res.status_code(), StatusCode::OK); let res_place: Place = res.json(); // The inserted place should have an ID assert!(res_place.id.is_some()); // Add the returned ID to the original place p.id = res_place.id; } // delete the first one let res = server .delete(&format!("/places/{}", places[0].id.unwrap())) .await; // We should get a success on the request assert_eq!(res.status_code(), StatusCode::OK); // fetch the remaining places let res = server.get("/places").await; // We should get a success on the request assert_eq!(res.status_code(), StatusCode::OK); let res_places: Vec = res.json(); // we should only get the second place assert_eq!(&places[1..], res_places.as_slice()); Ok(()) } #[sqlx::test] async fn test_delete_not_existing(pool: SqlitePool) -> Result<()> { let server = server(&pool)?; // Try to delete a non-existing place let res = server.delete("/places/33").await; // We should get the corresponding status code assert_eq!(res.status_code(), StatusCode::NOT_FOUND); Ok(()) } #[sqlx::test] async fn test_update(pool: SqlitePool) -> Result<()> { let server = server(&pool)?; let mut places = vec![ Place { id: None, name: "Sherlock Holmes".to_owned(), address: "221 B Baker Street, London".to_owned(), description: "Museum and Gift Shop".to_owned(), icon: "museum".to_owned(), latitude: 51.5237669, longitude: -0.1627829, open_hours: "Tu-Su 09:30-18:00".to_owned(), url: Some("https://www.sherlock-holmes.co.uk/".to_owned()), }, Place { id: None, name: "Museo Nacional de Historia Natural".to_owned(), address: "Parque Quinta Normal S/N, Santiago".to_owned(), description: "Museo".to_owned(), icon: "museum".to_owned(), latitude: -70.681838888889, longitude: -33.4421694444449, open_hours: "Tu-Su 10:00-18:00".to_owned(), url: Some("https://www.mnhn.gob.cl/".to_owned()), }, ]; // insert original place let res = server.put("/places").json(&places[0]).await; // We should get a success on the request assert_eq!(res.status_code(), StatusCode::OK); let res_place: Place = res.json(); // The inserted place should have an ID assert!(res_place.id.is_some()); // Add the returned ID to the new place so we can do the update places[1].id = res_place.id; // update the place let res = server.put("/places").json(&places[1]).await; // We should get a success on the request assert_eq!(res.status_code(), StatusCode::OK); // fetch the places let res = server.get("/places").await; // We should get a success on the request assert_eq!(res.status_code(), StatusCode::OK); let res_places: Vec = res.json(); // we should get the updated place assert_eq!(&places[1..], res_places.as_slice()); Ok(()) } }