chore: use native HTML5 dialog element and refactor backend into hexagonal architecture. (#48)

Reviewed-on: #48
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
This commit is contained in:
Felipe 2025-06-11 22:46:58 -04:00 committed by Felipe
parent f8540e5043
commit 78cb376791
Signed by: Ludwig
GPG key ID: 441A26F83D31FAFF
15 changed files with 880 additions and 618 deletions

154
Cargo.lock generated
View file

@ -34,9 +34,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.96" version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b964d184e89d9b6b67dd2715bc8e74cf3107fb2b529990c90cf517326150bf4" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]] [[package]]
name = "assert-json-diff" name = "assert-json-diff"
@ -77,9 +77,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]] [[package]]
name = "axum" name = "axum"
version = "0.8.1" version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5"
dependencies = [ dependencies = [
"axum-core", "axum-core",
"bytes", "bytes",
@ -111,12 +111,12 @@ dependencies = [
[[package]] [[package]]
name = "axum-core" name = "axum-core"
version = "0.5.0" version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733" checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6"
dependencies = [ dependencies = [
"bytes", "bytes",
"futures-util", "futures-core",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
@ -144,9 +144,9 @@ dependencies = [
[[package]] [[package]]
name = "axum-test" name = "axum-test"
version = "17.2.0" version = "17.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "317c1f4ecc1e68e0ad5decb78478421055c963ce215e736ed97463fa609cd196" checksum = "0eb1dfb84bd48bad8e4aa1acb82ed24c2bb5e855b659959b4e03b4dca118fcac"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"assert-json-diff", "assert-json-diff",
@ -232,9 +232,9 @@ checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9"
[[package]] [[package]]
name = "bytesize" name = "bytesize"
version = "1.3.2" version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d2c12f985c78475a6b8d629afd0c360260ef34cfef52efccdcfd31972f81c2e" checksum = "a3c8f83209414aacf0eeae3cf730b18d6981697fba62f200fcfb92b9f082acba"
[[package]] [[package]]
name = "cc" name = "cc"
@ -395,16 +395,6 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 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]] [[package]]
name = "etcetera" name = "etcetera"
version = "0.8.0" version = "0.8.0"
@ -427,12 +417,6 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]] [[package]]
name = "flume" name = "flume"
version = "0.11.1" version = "0.11.1"
@ -670,9 +654,9 @@ dependencies = [
[[package]] [[package]]
name = "http" name = "http"
version = "1.2.0" version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565"
dependencies = [ dependencies = [
"bytes", "bytes",
"fnv", "fnv",
@ -722,7 +706,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]] [[package]]
name = "huellas" name = "huellas"
version = "0.3.0" version = "0.3.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@ -732,6 +716,7 @@ dependencies = [
"futures", "futures",
"serde", "serde",
"sqlx", "sqlx",
"thiserror",
"tokio", "tokio",
"tower-http", "tower-http",
"tracing", "tracing",
@ -965,12 +950,6 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.7.4" version = "0.7.4"
@ -1376,11 +1355,10 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]] [[package]]
name = "reserve-port" name = "reserve-port"
version = "2.1.0" version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "359fc315ed556eb0e42ce74e76f4b1cd807b50fa6307f3de4e51f92dbe86e2d5" checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356"
dependencies = [ dependencies = [
"lazy_static",
"thiserror", "thiserror",
] ]
@ -1442,16 +1420,15 @@ dependencies = [
[[package]] [[package]]
name = "rust-multipart-rfc7578_2" name = "rust-multipart-rfc7578_2"
version = "0.7.0" version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc4bb9e7c9abe5fa5f30c2d8f8fefb9e0080a2c1e3c2e567318d2907054b35d3" checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41"
dependencies = [ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"futures-util", "futures-util",
"http", "http",
"mime", "mime",
"mime_guess",
"rand 0.9.0", "rand 0.9.0",
"thiserror", "thiserror",
] ]
@ -1462,19 +1439,6 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" 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]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.23" version = "0.23.23"
@ -1489,15 +1453,6 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.11.0" version = "1.11.0"
@ -1535,18 +1490,18 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.218" version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60" checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.218" version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b" checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -1692,9 +1647,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlx" name = "sqlx"
version = "0.8.3" version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4410e73b3c0d8442c5f99b425d7a435b5ee0ae4167b3196771dd3f7a01be745f" checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
dependencies = [ dependencies = [
"sqlx-core", "sqlx-core",
"sqlx-macros", "sqlx-macros",
@ -1705,10 +1660,11 @@ dependencies = [
[[package]] [[package]]
name = "sqlx-core" name = "sqlx-core"
version = "0.8.3" version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a007b6936676aa9ab40207cde35daab0a04b823be8ae004368c0793b96a61e0" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [ dependencies = [
"base64",
"bytes", "bytes",
"crc", "crc",
"crossbeam-queue", "crossbeam-queue",
@ -1726,7 +1682,6 @@ dependencies = [
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
"rustls", "rustls",
"rustls-pemfile",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@ -1741,9 +1696,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlx-macros" name = "sqlx-macros"
version = "0.8.3" version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3112e2ad78643fef903618d78cf0aec1cb3134b019730edb039b69eaf531f310" checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -1754,9 +1709,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlx-macros-core" name = "sqlx-macros-core"
version = "0.8.3" version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e9f90acc5ab146a99bf5061a7eb4976b573f560bc898ef3bf8435448dd5e7ad" checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
dependencies = [ dependencies = [
"dotenvy", "dotenvy",
"either", "either",
@ -1771,16 +1726,15 @@ dependencies = [
"sqlx-core", "sqlx-core",
"sqlx-sqlite", "sqlx-sqlite",
"syn", "syn",
"tempfile",
"tokio", "tokio",
"url", "url",
] ]
[[package]] [[package]]
name = "sqlx-mysql" name = "sqlx-mysql"
version = "0.8.3" version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64",
@ -1819,9 +1773,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlx-postgres" name = "sqlx-postgres"
version = "0.8.3" version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64",
@ -1856,9 +1810,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlx-sqlite" name = "sqlx-sqlite"
version = "0.8.3" version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f85ca71d3a5b24e64e1d08dd8fe36c6c95c339a896cc33068148906784620540" checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
dependencies = [ dependencies = [
"atoi", "atoi",
"flume", "flume",
@ -1873,6 +1827,7 @@ dependencies = [
"serde", "serde",
"serde_urlencoded", "serde_urlencoded",
"sqlx-core", "sqlx-core",
"thiserror",
"tracing", "tracing",
"url", "url",
] ]
@ -1928,34 +1883,20 @@ dependencies = [
"syn", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.11" version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl",
] ]
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.11" version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -2030,9 +1971,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.43.0" version = "1.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e" checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
dependencies = [ dependencies = [
"backtrace", "backtrace",
"bytes", "bytes",
@ -2098,12 +2039,13 @@ dependencies = [
[[package]] [[package]]
name = "tower-http" name = "tower-http"
version = "0.6.2" version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"bytes", "bytes",
"futures-core",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",

View file

@ -1,12 +1,12 @@
[package] [package]
name = "huellas" name = "huellas"
version = "0.3.0" version = "0.3.1"
edition = "2024" edition = "2024"
license = "AGPL-3.0" license = "AGPL-3.0"
[dependencies] [dependencies]
anyhow = "1.0.96" anyhow = "1.0.98"
axum = { version = "0.8.1", default-features = false, features = [ axum = { version = "0.8.4", default-features = false, features = [
"tracing", "tracing",
"tokio", "tokio",
"http1", "http1",
@ -15,20 +15,21 @@ axum = { version = "0.8.1", default-features = false, features = [
axum-msgpack = "0.5.0" axum-msgpack = "0.5.0"
dotenvy = "0.15.7" dotenvy = "0.15.7"
futures = { version = "0.3.31", default-features = false } futures = { version = "0.3.31", default-features = false }
serde = { version = "1.0.218", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
sqlx = { version = "0.8.3", default-features = false, features = [ sqlx = { version = "0.8.6", default-features = false, features = [
"macros", "macros",
"migrate", "migrate",
"runtime-tokio", "runtime-tokio",
"sqlite", "sqlite",
"tls-rustls", "tls-rustls",
] } ] }
tokio = { version = "1.43.0", default-features = false, features = [ tokio = { version = "1.45.1", default-features = false, features = [
"macros", "macros",
"rt-multi-thread", "rt-multi-thread",
"signal", "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 = "0.1.41"
tracing-subscriber = { version = "0.3.19", default-features = false, features = [ tracing-subscriber = { version = "0.3.19", default-features = false, features = [
"env-filter", "env-filter",
@ -38,4 +39,4 @@ tracing-subscriber = { version = "0.3.19", default-features = false, features =
] } ] }
[dev-dependencies] [dev-dependencies]
axum-test = { version = "17.2.0", features = ["msgpack"] } axum-test = { version = "17.3.0", features = ["msgpack"] }

26
src/db.rs Normal file
View file

@ -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<SqlitePool> {
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(())
}

20
src/logging.rs Normal file
View file

@ -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(())
}

View file

@ -1,61 +1,22 @@
use anyhow::{Context, Result}; use anyhow::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};
mod place; mod db;
mod routes; mod logging;
mod places;
mod server;
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
dotenvy::dotenv().unwrap_or_default(); dotenvy::dotenv().unwrap_or_default();
tracing_subscriber::registry() logging::setup()?;
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "huellas=debug".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let db_url = dotenvy::var("DATABASE_URL").context("DATABASE_URL not defined")?; let pool = db::pool().await?;
let pool = SqlitePool::connect(&db_url) db::run_migrations(&pool).await?;
.await
.context("Couldn't connect to database")?;
sqlx::migrate!() let places_repository = places::db_repository::DbPlacesRepository::new(pool);
.run(&pool) let places_routes = places::routes::places_routes(places_repository);
.await
.context("Couldn't run migrations")?;
let app = Router::new() server::serve(places_routes).await?;
.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?;
Ok(()) Ok(())
} }
async fn shutdown_signal() {
tokio::signal::ctrl_c()
.await
.expect("failed to listen for ctrl-c");
tracing::debug!("Received shutdown signal");
}

View file

@ -1,14 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, PartialEq, Serialize)]
pub struct Place {
pub id: Option<i64>,
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<String>,
}

287
src/places/db_repository.rs Normal file
View file

@ -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<Vec<super::models::Place>, 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::<Vec<_>>()
.await
.map_err(|err| PlacesError::FailToGet(err.to_string()))
}
async fn insert_place(&self, place: PlaceInsert) -> Result<Place, PlacesError> {
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<Place, PlacesError> {
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::<Vec<_>>();
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::<Vec<_>>();
// 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::<Vec<_>>();
// we should get the updated place
assert_eq!(&places[1..], res_places.as_slice());
Ok(())
}
}

4
src/places/mod.rs Normal file
View file

@ -0,0 +1,4 @@
pub mod db_repository;
pub mod models;
pub mod repository;
pub mod routes;

119
src/places/models.rs Normal file
View file

@ -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<String>,
}
/// 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<String>,
}
/// UpsertPlace payload
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct PlaceUpsert {
pub id: Option<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<String>,
}
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<PlaceUpsert> for (PlaceInsert, Option<i64>) {
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<Place> 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,
)
}
}

105
src/places/repository.rs Normal file
View file

@ -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<Output = Result<Vec<Place>, PlacesError>> + Send;
/// Inserts a Place.
fn insert_place(
&self,
place: PlaceInsert,
) -> impl Future<Output = Result<Place, PlacesError>> + Send;
/// Updates a Place.
fn update_place(&self, place: Place)
-> impl Future<Output = Result<Place, PlacesError>> + Send;
/// Deletes the place for the given `id`.
fn delete_place(&self, id: i64) -> impl Future<Output = Result<(), PlacesError>> + 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<RwLock<usize>>,
insert_place_count: Arc<RwLock<usize>>,
update_place_count: Arc<RwLock<usize>>,
delete_place_count: Arc<RwLock<usize>>,
}
#[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<Vec<Place>, 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<Place, PlacesError> {
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<Place, PlacesError> {
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(())
}
}

145
src/places/routes.rs Normal file
View file

@ -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<T, E = (StatusCode, String)> = std::result::Result<T, E>;
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<PR: PlacesRepository>(
State(repository): State<PR>,
) -> Result<MsgPack<Vec<Place>>> {
let places = repository.get_places().await.map_err(internal_error)?;
Ok(MsgPack(places))
}
async fn upsert_place<PR: PlacesRepository>(
State(repository): State<PR>,
MsgPack(place): MsgPack<PlaceUpsert>,
) -> Result<MsgPack<Place>> {
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<PR: PlacesRepository>(
State(repository): State<PR>,
Path(id): Path<i64>,
) -> Result<()> {
repository.delete_place(id).await.map_err(internal_error)?;
Ok(())
}
pub fn places_routes<PR: PlacesRepository>(repository: PR) -> Router {
Router::new()
.route("/", get(get_places::<PR>))
.route("/", put(upsert_place::<PR>))
.route("/{id}", delete(delete_place::<PR>))
.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<Place> = 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(())
}
}

View file

@ -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<T, E = (StatusCode, String)> = std::result::Result<T, E>;
fn internal_error<E>(err: E) -> (StatusCode, String)
where
E: std::error::Error,
{
(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
}
async fn get_places(State(pool): State<SqlitePool>) -> Result<MsgPack<Vec<Place>>> {
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::<Vec<_>>()
.await
.map_err(internal_error)?;
Ok(MsgPack(places))
}
async fn upsert_place(
State(pool): State<SqlitePool>,
MsgPack(place): MsgPack<Place>,
) -> Result<MsgPack<Place>> {
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<MsgPack<Place>> {
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<MsgPack<Place>> {
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<SqlitePool>, Path(id): Path<i64>) -> 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<TestServer> {
let router = Router::new().nest("/places", places_routes(pool.clone()));
TestServer::new(router)
}
async fn get_from_db(pool: &SqlitePool, id: i64) -> Result<Place> {
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<Place> = 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<Place> = 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<Place> = res.msgpack();
// we should get the updated place
assert_eq!(&places[1..], res_places.as_slice());
Ok(())
}
}

37
src/server.rs Normal file
View file

@ -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");
}

View file

@ -17,8 +17,8 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet-contextmenu/1.4.0/leaflet.contextmenu.min.js" <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet-contextmenu/1.4.0/leaflet.contextmenu.min.js"
integrity="sha512-8sfQf8cr0KjCeN32YPfjvLU2cMvyY1lhCXTMfpTZ16CvwIzeVQtwtKlxeSqFs/TpXjKhp1Dcv77LQmn1VFaOZg==" integrity="sha512-8sfQf8cr0KjCeN32YPfjvLU2cMvyY1lhCXTMfpTZ16CvwIzeVQtwtKlxeSqFs/TpXjKhp1Dcv77LQmn1VFaOZg=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script> crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script crossorigin src="https://unpkg.com/@msgpack/msgpack" <script crossorigin src="https://unpkg.com/@msgpack/msgpack@3.1.2/dist.umd/msgpack.min.js"
integrity="sha512-t/LymXW9iw9p0bciQ6uASEj+8XE/p+07CCmCyNwn06F4FK2s80IuHCY62bfSorcD4ktOJavXApp5rqtIVlg3+g==" integrity="sha512-B9xeVWeBMLLUlFALrj2/h3IY/N7MJSkzBrwIltslJSlfWdPsQsQinFJ3X9PuAsz695c5qy5U0194ZqZTg8H3yg=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script> crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<style type="text/css" media="screen"> <style type="text/css" media="screen">
@ -26,12 +26,10 @@
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
html, body, #map { html, body, #map {
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
} }
.leaflet-popup-content h3 { .leaflet-popup-content h3 {
margin-top: 1em; margin-top: 1em;
margin-bottom: 0.5em; margin-bottom: 0.5em;
@ -41,20 +39,8 @@
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
#modal { dialog {
display: none;
position: fixed;
z-index: 400;
left: 0;
top: 0;
height: 100vh;
width: 100vw;
overflow: auto;
background-color: rgb(0,0,0);
background-color: rgba(0,0,0,0.4);
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
}
#modal-form {
margin: 15vh auto; margin: 15vh auto;
color: #333; color: #333;
background-color: white; background-color: white;
@ -63,6 +49,9 @@
width: 80vw; width: 80vw;
border-radius: 12px; border-radius: 12px;
} }
dialog::backdrop {
background-color: rgba(0,0,0,0.4);
}
#close { #close {
color: #333; color: #333;
float: right; float: right;
@ -94,8 +83,7 @@
</head> </head>
<body> <body>
<div id="map"></div> <div id="map"></div>
<div id="modal"> <dialog id="dialog">
<div id="modal-form">
<span id="close">&times;</span> <span id="close">&times;</span>
<h1>Título</h1> <h1>Título</h1>
<form> <form>
@ -152,8 +140,7 @@
<button type="button" id="button">Enviar </button> <button type="button" id="button">Enviar </button>
</p> </p>
</form> </form>
</div> </dialog>
</div>
<script src="client.js" onload="setupMap()"></script> <script src="client.js" onload="setupMap()"></script>
</body> </body>
</html> </html>

View file

@ -113,7 +113,7 @@ function clearForm(): void {
document.getElementById("button").onclick = null; document.getElementById("button").onclick = null;
/* Now you see it, now you don't*/ /* Now you see it, now you don't*/
document.getElementById("modal").style.display = "none"; (document.getElementById("dialog") as HTMLDialogElement).close();
} }
async function createPlace(): Promise<void> { async function createPlace(): Promise<void> {
@ -190,22 +190,16 @@ function toLink(url: string): string {
async function setupMap(): Promise<void> { async function setupMap(): Promise<void> {
/* Create/Edit form */ /* Create/Edit form */
const modal = document.getElementById("modal"); const dialog = document.getElementById("dialog") as HTMLDialogElement;
const closeButton = document.getElementById("close"); const closeButton = document.getElementById("close");
closeButton.onclick = function() { closeButton.onclick = function() {
modal.style.display = "none"; dialog.close();
}
window.onclick = function(e: Event) {
if (e.target == modal) {
modal.style.display = "none";
}
} }
async function openForm(op: Operation, lat: number, long: number): Promise<void> { async function openForm(op: Operation, lat: number, long: number): Promise<void> {
/* Fill the form for us */ /* Fill the form for us */
const h1 = modal.getElementsByTagName("h1")[0]; const h1 = dialog.getElementsByTagName("h1")[0];
if (op == Operation.Create) { if (op == Operation.Create) {
clearForm() clearForm()
h1.innerText = "Añadir lugar nuevo"; h1.innerText = "Añadir lugar nuevo";
@ -255,7 +249,7 @@ async function setupMap(): Promise<void> {
} }
/* Make it appear */ /* Make it appear */
modal.style.display = "block"; dialog.showModal();
} }
function openCreateForm(e: MapEvent) { function openCreateForm(e: MapEvent) {