From 78cb3767911b5e5ba84c6eba5a977ce9deb2da68 Mon Sep 17 00:00:00 2001 From: Felipe Contreras Salinas Date: Wed, 11 Jun 2025 22:46:58 -0400 Subject: [PATCH] chore: use native HTML5 dialog element and refactor backend into hexagonal architecture. (#48) Reviewed-on: https://oolong.ludwig.dog/pitbuster/huellas/pulls/48 Co-authored-by: Felipe Contreras Salinas Co-committed-by: Felipe Contreras Salinas --- Cargo.lock | 154 +++++----------- Cargo.toml | 17 +- src/db.rs | 26 +++ src/logging.rs | 20 ++ src/main.rs | 61 ++----- src/place.rs | 14 -- src/places/db_repository.rs | 287 +++++++++++++++++++++++++++++ src/places/mod.rs | 4 + src/places/models.rs | 119 ++++++++++++ src/places/repository.rs | 105 +++++++++++ src/places/routes.rs | 145 +++++++++++++++ src/routes.rs | 352 ------------------------------------ src/server.rs | 37 ++++ static/index.html | 141 +++++++-------- ts-client/client.ts | 16 +- 15 files changed, 880 insertions(+), 618 deletions(-) create mode 100644 src/db.rs create mode 100644 src/logging.rs delete mode 100644 src/place.rs create mode 100644 src/places/db_repository.rs create mode 100644 src/places/mod.rs create mode 100644 src/places/models.rs create mode 100644 src/places/repository.rs create mode 100644 src/places/routes.rs delete mode 100644 src/routes.rs create mode 100644 src/server.rs diff --git a/Cargo.lock b/Cargo.lock index 536fd19..715af83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,9 +34,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "anyhow" -version = "1.0.96" +version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b964d184e89d9b6b67dd2715bc8e74cf3107fb2b529990c90cf517326150bf4" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" [[package]] name = "assert-json-diff" @@ -77,9 +77,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "axum" -version = "0.8.1" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" +checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" dependencies = [ "axum-core", "bytes", @@ -111,12 +111,12 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733" +checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" dependencies = [ "bytes", - "futures-util", + "futures-core", "http", "http-body", "http-body-util", @@ -144,9 +144,9 @@ dependencies = [ [[package]] name = "axum-test" -version = "17.2.0" +version = "17.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317c1f4ecc1e68e0ad5decb78478421055c963ce215e736ed97463fa609cd196" +checksum = "0eb1dfb84bd48bad8e4aa1acb82ed24c2bb5e855b659959b4e03b4dca118fcac" dependencies = [ "anyhow", "assert-json-diff", @@ -232,9 +232,9 @@ checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9" [[package]] name = "bytesize" -version = "1.3.2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2c12f985c78475a6b8d629afd0c360260ef34cfef52efccdcfd31972f81c2e" +checksum = "a3c8f83209414aacf0eeae3cf730b18d6981697fba62f200fcfb92b9f082acba" [[package]] name = "cc" @@ -395,16 +395,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "errno" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "etcetera" version = "0.8.0" @@ -427,12 +417,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - [[package]] name = "flume" version = "0.11.1" @@ -670,9 +654,9 @@ dependencies = [ [[package]] name = "http" -version = "1.2.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" dependencies = [ "bytes", "fnv", @@ -722,7 +706,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "huellas" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "axum", @@ -732,6 +716,7 @@ dependencies = [ "futures", "serde", "sqlx", + "thiserror", "tokio", "tower-http", "tracing", @@ -965,12 +950,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "litemap" version = "0.7.4" @@ -1376,11 +1355,10 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reserve-port" -version = "2.1.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "359fc315ed556eb0e42ce74e76f4b1cd807b50fa6307f3de4e51f92dbe86e2d5" +checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" dependencies = [ - "lazy_static", "thiserror", ] @@ -1442,16 +1420,15 @@ dependencies = [ [[package]] name = "rust-multipart-rfc7578_2" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc4bb9e7c9abe5fa5f30c2d8f8fefb9e0080a2c1e3c2e567318d2907054b35d3" +checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41" dependencies = [ "bytes", "futures-core", "futures-util", "http", "mime", - "mime_guess", "rand 0.9.0", "thiserror", ] @@ -1462,19 +1439,6 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.59.0", -] - [[package]] name = "rustls" version = "0.23.23" @@ -1489,15 +1453,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.11.0" @@ -1535,18 +1490,18 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.218" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.218" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", @@ -1692,9 +1647,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4410e73b3c0d8442c5f99b425d7a435b5ee0ae4167b3196771dd3f7a01be745f" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ "sqlx-core", "sqlx-macros", @@ -1705,10 +1660,11 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a007b6936676aa9ab40207cde35daab0a04b823be8ae004368c0793b96a61e0" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ + "base64", "bytes", "crc", "crossbeam-queue", @@ -1726,7 +1682,6 @@ dependencies = [ "once_cell", "percent-encoding", "rustls", - "rustls-pemfile", "serde", "serde_json", "sha2", @@ -1741,9 +1696,9 @@ dependencies = [ [[package]] name = "sqlx-macros" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3112e2ad78643fef903618d78cf0aec1cb3134b019730edb039b69eaf531f310" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ "proc-macro2", "quote", @@ -1754,9 +1709,9 @@ dependencies = [ [[package]] name = "sqlx-macros-core" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e9f90acc5ab146a99bf5061a7eb4976b573f560bc898ef3bf8435448dd5e7ad" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", @@ -1771,16 +1726,15 @@ dependencies = [ "sqlx-core", "sqlx-sqlite", "syn", - "tempfile", "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64", @@ -1819,9 +1773,9 @@ dependencies = [ [[package]] name = "sqlx-postgres" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64", @@ -1856,9 +1810,9 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f85ca71d3a5b24e64e1d08dd8fe36c6c95c339a896cc33068148906784620540" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", "flume", @@ -1873,6 +1827,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", + "thiserror", "tracing", "url", ] @@ -1928,34 +1883,20 @@ dependencies = [ "syn", ] -[[package]] -name = "tempfile" -version = "3.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230" -dependencies = [ - "cfg-if", - "fastrand", - "getrandom 0.3.1", - "once_cell", - "rustix", - "windows-sys 0.59.0", -] - [[package]] name = "thiserror" -version = "2.0.11" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.11" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", @@ -2030,9 +1971,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.43.0" +version = "1.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e" +checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" dependencies = [ "backtrace", "bytes", @@ -2098,12 +2039,13 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.2" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ "bitflags", "bytes", + "futures-core", "futures-util", "http", "http-body", diff --git a/Cargo.toml b/Cargo.toml index 8bccfcd..dac5bb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "huellas" -version = "0.3.0" +version = "0.3.1" edition = "2024" license = "AGPL-3.0" [dependencies] -anyhow = "1.0.96" -axum = { version = "0.8.1", default-features = false, features = [ +anyhow = "1.0.98" +axum = { version = "0.8.4", default-features = false, features = [ "tracing", "tokio", "http1", @@ -15,20 +15,21 @@ axum = { version = "0.8.1", default-features = false, features = [ axum-msgpack = "0.5.0" dotenvy = "0.15.7" futures = { version = "0.3.31", default-features = false } -serde = { version = "1.0.218", features = ["derive"] } -sqlx = { version = "0.8.3", default-features = false, features = [ +serde = { version = "1.0.219", features = ["derive"] } +sqlx = { version = "0.8.6", default-features = false, features = [ "macros", "migrate", "runtime-tokio", "sqlite", "tls-rustls", ] } -tokio = { version = "1.43.0", default-features = false, features = [ +tokio = { version = "1.45.1", default-features = false, features = [ "macros", "rt-multi-thread", "signal", ] } -tower-http = { version = "0.6.2", default-features = false, features = ["fs"] } +thiserror = "2.0.12" +tower-http = { version = "0.6.6", default-features = false, features = ["fs"] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", default-features = false, features = [ "env-filter", @@ -38,4 +39,4 @@ tracing-subscriber = { version = "0.3.19", default-features = false, features = ] } [dev-dependencies] -axum-test = { version = "17.2.0", features = ["msgpack"] } +axum-test = { version = "17.3.0", features = ["msgpack"] } diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..ba9fe59 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,26 @@ +//! Database handling. + +use anyhow::{Context, Result}; +use sqlx::SqlitePool; + +/// Creates a Database Pool +/// +/// # Errors +/// This function may return an error if the `DATABASE_URL` environment is not defined or if the +/// database that URL points to is not reachable for some reason. +pub async fn pool() -> Result { + let db_url = std::env::var("DATABASE_URL").context("DATABASE_URL not defined")?; + let pool = SqlitePool::connect(&db_url) + .await + .context("Couldn't connect to database")?; + Ok(pool) +} + +/// Run migrations on the database `pool` is connected to. +pub async fn run_migrations(pool: &SqlitePool) -> Result<()> { + sqlx::migrate!() + .run(pool) + .await + .context("Couldn't run migrations")?; + Ok(()) +} diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..78e8dc9 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,20 @@ +//! Service logging. + +use anyhow::Result; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +/// Setups logging. +/// +/// # Errors +/// This function can return an error if called repeatedly or if logging/tracing was already setup +/// by another means. +pub fn setup() -> Result<()> { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "huellas=debug".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .try_init()?; + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index ba58378..150dd55 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,61 +1,22 @@ -use anyhow::{Context, Result}; -use axum::Router; -use axum::serve::ListenerExt; -use sqlx::sqlite::SqlitePool; -use std::net::SocketAddr; -use tower_http::services::ServeDir; -use tracing::trace; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use anyhow::Result; -mod place; -mod routes; +mod db; +mod logging; +mod places; +mod server; #[tokio::main] async fn main() -> Result<()> { dotenvy::dotenv().unwrap_or_default(); - tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "huellas=debug".into()), - ) - .with(tracing_subscriber::fmt::layer()) - .init(); + logging::setup()?; - let db_url = dotenvy::var("DATABASE_URL").context("DATABASE_URL not defined")?; - let pool = SqlitePool::connect(&db_url) - .await - .context("Couldn't connect to database")?; + let pool = db::pool().await?; + db::run_migrations(&pool).await?; - sqlx::migrate!() - .run(&pool) - .await - .context("Couldn't run migrations")?; + let places_repository = places::db_repository::DbPlacesRepository::new(pool); + let places_routes = places::routes::places_routes(places_repository); - let app = Router::new() - .nest("/places", routes::places_routes(pool)) - .nest_service("/", ServeDir::new("static")); - - let port = dotenvy::var("PORT").unwrap_or_default(); - let port = str::parse(&port).unwrap_or(3000); - let address = SocketAddr::from(([0, 0, 0, 0], port)); - tracing::debug!("listening on {}", address); - let listener = tokio::net::TcpListener::bind(address) - .await? - .tap_io(|tcp_stream| { - if let Err(err) = tcp_stream.set_nodelay(true) { - trace!("failed to set TCP_NODELAY on incoming connection: {err:#}"); - } - }); - axum::serve(listener, app.into_make_service()) - .with_graceful_shutdown(shutdown_signal()) - .await?; + server::serve(places_routes).await?; Ok(()) } - -async fn shutdown_signal() { - tokio::signal::ctrl_c() - .await - .expect("failed to listen for ctrl-c"); - tracing::debug!("Received shutdown signal"); -} diff --git a/src/place.rs b/src/place.rs deleted file mode 100644 index 0a06c24..0000000 --- a/src/place.rs +++ /dev/null @@ -1,14 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Deserialize, PartialEq, Serialize)] -pub struct Place { - pub id: Option, - pub name: String, - pub address: String, - pub open_hours: String, - pub icon: String, - pub description: String, - pub longitude: f64, - pub latitude: f64, - pub url: Option, -} diff --git a/src/places/db_repository.rs b/src/places/db_repository.rs new file mode 100644 index 0000000..8d8d91c --- /dev/null +++ b/src/places/db_repository.rs @@ -0,0 +1,287 @@ +//! `PlacesRepository` that is backed by a DB. + +use futures::TryStreamExt; +use sqlx::SqlitePool; + +use crate::places::models::PlaceInsert; + +use super::models::Place; +use super::repository::{PlacesError, PlacesRepository}; + +#[derive(Clone)] +pub struct DbPlacesRepository { + db_pool: SqlitePool, +} + +impl DbPlacesRepository { + pub fn new(db_pool: SqlitePool) -> Self { + Self { db_pool } + } +} + +impl PlacesRepository for DbPlacesRepository { + async fn get_places(&self) -> Result, PlacesError> { + 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(&self.db_pool) + .map_ok(|p| Place { + id: 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(|err| PlacesError::FailToGet(err.to_string())) + } + + async fn insert_place(&self, place: PlaceInsert) -> 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(&self.db_pool) + .await + .map_err(|err| PlacesError::FailToUpsert(err.to_string()))?; + + Ok((place, id).into()) + } + + async fn update_place(&self, 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(&self.db_pool) + .await + .map_err(|err| PlacesError::FailToUpsert(err.to_string()))?; + + if result.rows_affected() == 1 { + Ok(place) + } else { + Err(PlacesError::NotFound(place.id)) + } + } + + async fn delete_place(&self, id: i64) -> Result<(), PlacesError> { + let result = ::sqlx::query!("UPDATE places SET active = FALSE WHERE id = ?", id) + .execute(&self.db_pool) + .await + .map_err(|err| PlacesError::FailToDelete(err.to_string()))?; + + if result.rows_affected() == 1 { + Ok(()) + } else { + Err(PlacesError::NotFound(id)) + } + } +} + +mod tests { + #![cfg(test)] + use super::DbPlacesRepository; + use crate::places::models::PlaceInsert; + use crate::places::repository::{PlacesError, PlacesRepository}; + + use anyhow::Result; + use futures::future::try_join_all; + use sqlx::sqlite::SqlitePool; + + #[sqlx::test] + async fn test_add_place(pool: SqlitePool) -> Result<()> { + let repository = DbPlacesRepository::new(pool); + let place = PlaceInsert { + 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_place = repository.insert_place(place.clone()).await?; + let (res_place, _) = res_place.into(); + // And now they should be equal + assert_eq!(place, res_place); + Ok(()) + } + + #[sqlx::test] + async fn test_get_places(pool: SqlitePool) -> Result<()> { + let repository = DbPlacesRepository::new(pool); + let places = vec![ + PlaceInsert { + 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()), + }, + PlaceInsert { + 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 place in &places { + let _res_place = repository.insert_place(place.clone()).await?; + } + // and fetch them + let mut res_places = repository.get_places().await?; + // and they should be equal + res_places.sort_by(|a, b| a.id.cmp(&b.id)); + let res_places = res_places + .into_iter() + .map(|p| { + let (p, _id): (PlaceInsert, i64) = p.into(); + p + }) + .collect::>(); + assert_eq!(places, res_places); + Ok(()) + } + + #[sqlx::test] + async fn test_delete(pool: SqlitePool) -> Result<()> { + let repository = DbPlacesRepository::new(pool); + let places = vec![ + PlaceInsert { + 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()), + }, + PlaceInsert { + 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 + let ids = try_join_all(places.iter().map(|place| async { + let res_place = repository.insert_place(place.clone()).await?; + Ok::<_, PlacesError>(res_place.id) + })) + .await?; + // delete the first one + repository.delete_place(ids[0]).await?; + + // fetch the remaining places + let res_places = repository.get_places().await?; + let res_places = res_places + .into_iter() + .map(|p| { + let (p, _id) = p.into(); + p + }) + .collect::>(); + // 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 repository = DbPlacesRepository::new(pool); + // Try to delete a non-existing place + let res = repository.delete_place(33).await; + assert!(res.is_err_and(|err| err == PlacesError::NotFound(33))); + Ok(()) + } + + #[sqlx::test] + async fn test_update(pool: SqlitePool) -> Result<()> { + let repository = DbPlacesRepository::new(pool); + let places = vec![ + PlaceInsert { + 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()), + }, + PlaceInsert { + 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 = repository.insert_place(places[0].clone()).await?; + // Add the returned ID to the new place so we can do the update + let place = (places[1].clone(), res.id).into(); + // update the place + let _res = repository.update_place(place).await?; + + // fetch the places + let res_places = repository.get_places().await?; + let res_places = res_places + .into_iter() + .map(|p| { + let (p, _id) = p.into(); + p + }) + .collect::>(); + // we should get the updated place + assert_eq!(&places[1..], res_places.as_slice()); + Ok(()) + } +} diff --git a/src/places/mod.rs b/src/places/mod.rs new file mode 100644 index 0000000..6265c72 --- /dev/null +++ b/src/places/mod.rs @@ -0,0 +1,4 @@ +pub mod db_repository; +pub mod models; +pub mod repository; +pub mod routes; diff --git a/src/places/models.rs b/src/places/models.rs new file mode 100644 index 0000000..4781f6e --- /dev/null +++ b/src/places/models.rs @@ -0,0 +1,119 @@ +/// Models +use serde::{Deserialize, Serialize}; + +/// Place can be any place of interest we want to mark in a map +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct Place { + pub id: i64, + /// Name + pub name: String, + /// Address + pub address: String, + /// Opening Hours + pub open_hours: String, + /// Icon name + pub icon: String, + /// Description + pub description: String, + /// Longitude of the place + pub longitude: f64, + /// latitude of the place + pub latitude: f64, + /// URL for the place website + pub url: Option, +} + +/// Insert Place payload +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct PlaceInsert { + /// Name + pub name: String, + /// Address + pub address: String, + /// Opening Hours + pub open_hours: String, + /// Icon name + pub icon: String, + /// Description + pub description: String, + /// Longitude of the place + pub longitude: f64, + /// latitude of the place + pub latitude: f64, + /// URL for the place website + pub url: Option, +} + +/// UpsertPlace payload +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct PlaceUpsert { + pub id: Option, + /// Name + pub name: String, + /// Address + pub address: String, + /// Opening Hours + pub open_hours: String, + /// Icon name + pub icon: String, + /// Description + pub description: String, + /// Longitude of the place + pub longitude: f64, + /// latitude of the place + pub latitude: f64, + /// URL for the place website + pub url: Option, +} + +impl From<(PlaceInsert, i64)> for Place { + fn from((place, id): (PlaceInsert, i64)) -> Self { + Self { + id, + name: place.name, + address: place.address, + open_hours: place.open_hours, + icon: place.icon, + description: place.description, + longitude: place.longitude, + latitude: place.latitude, + url: place.url, + } + } +} + +impl From for (PlaceInsert, Option) { + fn from(place: PlaceUpsert) -> Self { + ( + PlaceInsert { + name: place.name, + address: place.address, + open_hours: place.open_hours, + icon: place.icon, + description: place.description, + longitude: place.longitude, + latitude: place.latitude, + url: place.url, + }, + place.id, + ) + } +} + +impl From for (PlaceInsert, i64) { + fn from(place: Place) -> Self { + ( + PlaceInsert { + name: place.name, + address: place.address, + open_hours: place.open_hours, + icon: place.icon, + description: place.description, + longitude: place.longitude, + latitude: place.latitude, + url: place.url, + }, + place.id, + ) + } +} diff --git a/src/places/repository.rs b/src/places/repository.rs new file mode 100644 index 0000000..516a32c --- /dev/null +++ b/src/places/repository.rs @@ -0,0 +1,105 @@ +//! Places Repository +#[cfg(test)] +use std::sync::Arc; + +use thiserror::Error; +#[cfg(test)] +use tokio::sync::RwLock; + +use super::models::{Place, PlaceInsert}; + +/// Trait to handle Places. +pub trait PlacesRepository: Clone + Send + Sync + 'static { + /// Get all of the Places + fn get_places(&self) -> impl Future, PlacesError>> + Send; + + /// Inserts a Place. + fn insert_place( + &self, + place: PlaceInsert, + ) -> impl Future> + Send; + + /// Updates a Place. + fn update_place(&self, place: Place) + -> impl Future> + Send; + + /// Deletes the place for the given `id`. + fn delete_place(&self, id: i64) -> impl Future> + Send; +} + +#[derive(Debug, Error, PartialEq)] +pub enum PlacesError { + #[error("Couldn't retrieve places: {0}")] + FailToGet(String), + #[error("Couldn't upsert place: {0}")] + FailToUpsert(String), + #[error("Couldn't delete place: {0}")] + FailToDelete(String), + #[error("Place with id {0} not found")] + NotFound(i64), +} + +#[cfg(test)] +#[derive(Clone)] +pub struct MockPlacesRepository { + get_places_count: Arc>, + insert_place_count: Arc>, + update_place_count: Arc>, + delete_place_count: Arc>, +} + +#[cfg(test)] +impl MockPlacesRepository { + pub fn new() -> Self { + Self { + get_places_count: Arc::new(RwLock::new(0)), + insert_place_count: Arc::new(RwLock::new(0)), + update_place_count: Arc::new(RwLock::new(0)), + delete_place_count: Arc::new(RwLock::new(0)), + } + } + + pub async fn get_places_count(&self) -> usize { + *self.get_places_count.read().await + } + + pub async fn insert_place_count(&self) -> usize { + *self.insert_place_count.read().await + } + + pub async fn update_place_count(&self) -> usize { + *self.update_place_count.read().await + } + + pub async fn delete_place_count(&self) -> usize { + *self.delete_place_count.read().await + } +} + +#[cfg(test)] +impl PlacesRepository for MockPlacesRepository { + async fn get_places(&self) -> Result, PlacesError> { + let mut get_places_count = self.get_places_count.write().await; + *get_places_count += 1; + Ok(Vec::new()) + } + + async fn insert_place(&self, place: super::models::PlaceInsert) -> Result { + let mut insert_place_count = self.insert_place_count.write().await; + *insert_place_count += 1; + let place: Place = (place, 0).into(); + Ok(place) + } + + async fn update_place(&self, place: Place) -> Result { + let mut update_place_count = self.update_place_count.write().await; + *update_place_count += 1; + Ok(place) + } + + async fn delete_place(&self, _id: i64) -> Result<(), PlacesError> { + let mut delete_place_count = self.delete_place_count.write().await; + *delete_place_count += 1; + Ok(()) + } +} diff --git a/src/places/routes.rs b/src/places/routes.rs new file mode 100644 index 0000000..ecb5150 --- /dev/null +++ b/src/places/routes.rs @@ -0,0 +1,145 @@ +use axum::Router; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::routing::{delete, get, put}; +use axum_msgpack::MsgPack; + +use super::models::{Place, PlaceUpsert}; +use super::repository::{PlacesError, PlacesRepository}; + +type Result = std::result::Result; + +fn internal_error(err: PlacesError) -> (StatusCode, String) { + match err { + PlacesError::FailToGet(_) | PlacesError::FailToUpsert(_) | PlacesError::FailToDelete(_) => { + (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + } + PlacesError::NotFound(_) => (StatusCode::NOT_FOUND, err.to_string()), + } +} + +async fn get_places( + State(repository): State, +) -> Result>> { + let places = repository.get_places().await.map_err(internal_error)?; + Ok(MsgPack(places)) +} + +async fn upsert_place( + State(repository): State, + MsgPack(place): MsgPack, +) -> Result> { + let place = match place.into() { + (place, Some(id)) => repository.update_place((place, id).into()).await, + (place, None) => repository.insert_place(place).await, + } + .map_err(internal_error)?; + Ok(MsgPack(place)) +} + +async fn delete_place( + State(repository): State, + Path(id): Path, +) -> Result<()> { + repository.delete_place(id).await.map_err(internal_error)?; + Ok(()) +} + +pub fn places_routes(repository: PR) -> Router { + Router::new() + .route("/", get(get_places::)) + .route("/", put(upsert_place::)) + .route("/{id}", delete(delete_place::)) + .with_state(repository) +} + +mod tests { + #![cfg(test)] + use super::places_routes; + use crate::places::models::{Place, PlaceUpsert}; + use crate::places::repository::MockPlacesRepository; + + use anyhow::Result; + use axum::Router; + use axum::http::StatusCode; + use axum_test::TestServer; + + fn setup_server() -> Result<(TestServer, MockPlacesRepository)> { + let places_repository = MockPlacesRepository::new(); + let router = Router::new().nest("/places", places_routes(places_repository.clone())); + Ok((TestServer::new(router)?, places_repository)) + } + + #[tokio::test] + async fn test_add_place() -> Result<()> { + let (server, mock_repository) = setup_server()?; + let place = PlaceUpsert { + 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").msgpack(&place).await; + // We should get a success on the request + assert_eq!(res.status_code(), StatusCode::OK); + let _res_place: Place = res.msgpack(); + // The correct function should be called + assert_eq!(mock_repository.insert_place_count().await, 1); + Ok(()) + } + + #[tokio::test] + async fn test_get_places() -> Result<()> { + let (server, mock_repository) = setup_server()?; + // Get 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.msgpack(); + // and the correct function should be called + assert_eq!(mock_repository.get_places_count().await, 1); + Ok(()) + } + + #[tokio::test] + async fn test_delete() -> Result<()> { + let (server, mock_repository) = setup_server()?; + // Call delete + let res = server.delete("/places/0").await; + // We should get a success on the request + assert_eq!(res.status_code(), StatusCode::OK); + // The correct function should be called + assert_eq!(mock_repository.delete_place_count().await, 1); + Ok(()) + } + + #[tokio::test] + async fn test_update() -> Result<()> { + let (server, mock_repository) = setup_server()?; + let places = PlaceUpsert { + id: Some(1), + 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()), + }; + // upsert the place + let res = server.put("/places").msgpack(&places).await; + // We should get a success on the request + assert_eq!(res.status_code(), StatusCode::OK); + let _res_place: Place = res.msgpack(); + // The correct function should be called + assert_eq!(mock_repository.update_place_count().await, 1); + Ok(()) + } +} diff --git a/src/routes.rs b/src/routes.rs deleted file mode 100644 index 50a3bc6..0000000 --- a/src/routes.rs +++ /dev/null @@ -1,352 +0,0 @@ -use axum::Router; -use axum::extract::{Path, State}; -use axum::http::StatusCode; -use axum::routing::{delete, get}; -use axum_msgpack::MsgPack; -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(MsgPack(places)) -} - -async fn upsert_place( - State(pool): State, - MsgPack(place): MsgPack, -) -> 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(MsgPack(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(MsgPack(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::Router; - use axum::http::StatusCode; - 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").msgpack(&place).await; - // We should get a success on the request - assert_eq!(res.status_code(), StatusCode::OK); - let res_place: Place = res.msgpack(); - // 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").msgpack(&p).await; - // We should get a success on the request - assert_eq!(res.status_code(), StatusCode::OK); - let res_place: Place = res.msgpack(); - // 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.msgpack(); - // 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").msgpack(&p).await; - // We should get a success on the request - assert_eq!(res.status_code(), StatusCode::OK); - let res_place: Place = res.msgpack(); - // 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.msgpack(); - // 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").msgpack(&places[0]).await; - // We should get a success on the request - assert_eq!(res.status_code(), StatusCode::OK); - let res_place: Place = res.msgpack(); - // 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").msgpack(&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.msgpack(); - // we should get the updated place - assert_eq!(&places[1..], res_places.as_slice()); - Ok(()) - } -} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..08629b1 --- /dev/null +++ b/src/server.rs @@ -0,0 +1,37 @@ +//! HTTP Server + +use anyhow::Result; +use axum::Router; +use axum::serve::ListenerExt; +use std::net::SocketAddr; +use tower_http::services::ServeDir; + +pub async fn serve(place_routes: Router) -> Result<()> { + let port = std::env::var("PORT").unwrap_or_default(); + let port = str::parse(&port).unwrap_or(3000); + let address = SocketAddr::from(([0, 0, 0, 0], port)); + + let routes = Router::new() + .nest("/places", place_routes) + .fallback_service(ServeDir::new("static")); + + tracing::debug!("listening on {}", address); + let listener = tokio::net::TcpListener::bind(address) + .await? + .tap_io(|tcp_stream| { + if let Err(err) = tcp_stream.set_nodelay(true) { + tracing::trace!("failed to set TCP_NODELAY on incoming connection: {err:#}"); + } + }); + axum::serve(listener, routes.into_make_service()) + .with_graceful_shutdown(shutdown_signal()) + .await?; + Ok(()) +} + +async fn shutdown_signal() { + tokio::signal::ctrl_c() + .await + .expect("failed to listen for ctrl-c"); + tracing::debug!("Received shutdown signal"); +} diff --git a/static/index.html b/static/index.html index 8a68310..7cafc0d 100644 --- a/static/index.html +++ b/static/index.html @@ -17,8 +17,8 @@ -