diff --git a/.surrealdb b/.surrealdb new file mode 100644 index 0000000..522c56c --- /dev/null +++ b/.surrealdb @@ -0,0 +1,3 @@ +[core] +schema = "full" +path = "./surrealdb" \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index b5f9626..cbe0da7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -416,6 +416,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" dependencies = [ "axum-core", + "axum-macros", "bytes", "form_urlencoded", "futures-util", @@ -463,6 +464,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "axum-test" version = "18.0.2" @@ -4308,6 +4320,7 @@ dependencies = [ "config", "dotenvy", "fake", + "include_dir", "linkify", "mime", "proptest", diff --git a/Cargo.toml b/Cargo.toml index db97f8f..8cb8344 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,10 +14,13 @@ panic = 'abort' codegen-units = 1 [dependencies] -axum = { version = "0.8.4", features = ["tracing"] } +axum = { version = "0.8.4", features = ["tracing", "macros"] } config = "0.15.15" dotenvy = "0.15.7" -reqwest = { version = "0.12.23", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12.23", default-features = false, features = [ + "json", + "rustls-tls", +] } secrecy = { version = "0.10.3", features = ["serde"] } serde = { version = "1.0.219", features = ["derive"] } serde-humantime = "0.1.1" @@ -34,6 +37,7 @@ url = { version = "2.5.7", features = ["serde"] } validator = "0.20.0" serde_json = "1.0.143" rand = { version = "0.9.2", features = ["std_rng"] } +include_dir = "0.7.4" [dev-dependencies] mime = "0.3.17" diff --git a/src/domain/subscriber/mod.rs b/src/domain/subscriber/mod.rs index 5cbd11d..08df2e7 100644 --- a/src/domain/subscriber/mod.rs +++ b/src/domain/subscriber/mod.rs @@ -1,9 +1,8 @@ mod email; mod name; -pub use email::SubscriberEmail; - use crate::handlers::FormData; +pub use email::SubscriberEmail; use name::SubscriberName; use validator::ValidationErrors; diff --git a/src/errors.rs b/src/errors.rs index 05313d7..4813e93 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -5,7 +5,7 @@ pub type Result = std::result::Result; #[derive(thiserror::Error, Debug)] pub enum Error { #[error(transparent)] - SurrealDb(#[from] surrealdb::Error), + SurrealDb(#[from] Box), #[error("{0:?}")] Migrations(String), #[error(transparent)] diff --git a/src/handlers/health_check.rs b/src/handlers/health_check.rs index 4dd2711..89c4881 100644 --- a/src/handlers/health_check.rs +++ b/src/handlers/health_check.rs @@ -1,12 +1,13 @@ -use crate::Result; use crate::model::ModelManager; +use crate::{AppState, Result}; use axum::extract::State; use reqwest::StatusCode; use std::sync::Arc; +#[axum::debug_handler(state = AppState)] #[tracing::instrument(skip(mm))] pub async fn health(State(mm): State>) -> Result { - mm.db().await?.health().await?; + mm.db().await?.health().await.map_err(Box::new)?; Ok(StatusCode::OK) } diff --git a/src/handlers/subscription.rs b/src/handlers/subscription.rs index 587d534..24b39bc 100644 --- a/src/handlers/subscription.rs +++ b/src/handlers/subscription.rs @@ -1,7 +1,6 @@ -use crate::Config; -use crate::domain::Subscriber; -use crate::model::ModelManager; -use crate::{Result, email_client::EmailClient}; +use crate::{ + AppState, Config, Result, domain::Subscriber, email_client::EmailClient, model::ModelManager, +}; use axum::extract::Query; use axum::{Form, extract::State}; use rand::Rng; @@ -9,6 +8,7 @@ use rand::distr::Alphanumeric; use reqwest::StatusCode; use serde::Deserialize; use std::sync::Arc; +use url::Url; #[derive(Debug, Deserialize)] pub struct FormData { @@ -16,6 +16,7 @@ pub struct FormData { pub name: String, } +#[axum::debug_handler(state = AppState)] #[tracing::instrument(skip(mm, config, email_client))] pub async fn subscribe( State(mm): State>, @@ -39,36 +40,20 @@ pub struct Params { token: String, } +#[axum::debug_handler(state = AppState)] #[tracing::instrument(skip(mm))] pub async fn confirm( State(mm): State>, Query(params): Query, ) -> Result { - _ = mm - .db() - .await? - .query( - r#" - UPDATE subscriptions - SET status = 'CONFIRMED' - WHERE token = ( - SELECT id - FROM ONLY subscription_tokens - WHERE token = $token_val - ).id - AND status = 'PENDING'; - "#, - ) - .bind(("token_val", params.token)) - .await? - .take::(0)?; + mm.confirm_subscriber(params.token.clone()).await?; Ok(StatusCode::OK) } fn get_confirmation_token() -> String { - let mut rng = rand::rng(); - std::iter::repeat_with(|| rng.sample(Alphanumeric)) + rand::rng() + .sample_iter(&Alphanumeric) .map(char::from) .take(25) .collect() @@ -80,16 +65,14 @@ async fn send_confirmation_email( subscriber: &Subscriber, token: &str, ) -> Result<()> { - let mut confirmation_link = config.base_url.join("subscriptions/confirm")?; - confirmation_link.set_query(Some(&format!("token={token}"))); + let confirmation_link = get_confirmation_link(config, token)?; email_client .send_email( &subscriber.email, "Welcome!", &format!( - "Welcome to our newsletter!
\ - Click here to confirm your subscription.", + "Welcome to our newsletter!
Click here to confirm your subscription.", confirmation_link ), &format!( @@ -100,3 +83,10 @@ async fn send_confirmation_email( .await?; Ok(()) } + +fn get_confirmation_link(config: &Config, token: &str) -> Result { + let mut confirmation_link = config.base_url.join("subscriptions/confirm")?; + confirmation_link.set_query(Some(&format!("token={token}"))); + + Ok(confirmation_link) +} diff --git a/src/main.rs b/src/main.rs index 98d2a8b..4fb96c2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,9 @@ -use std::process; -use subscriptions::Config; +use subscriptions::{Config, Error}; use tokio::net::TcpListener; use tracing_subscriber::prelude::*; #[tokio::main] -async fn main() { +async fn main() -> Result<(), Error> { // Load environment variables from .env file if exists dotenvy::dotenv_override().ok(); @@ -15,33 +14,19 @@ async fn main() { .init(); // Initialize configuration - let config = Config::load().unwrap_or_else(|error| { - tracing::error!(%error, "Failed to load configuration"); - process::exit(1); - }); + let config = Config::load()?; // Initialize application - let (router, _) = subscriptions::init(config.clone()) - .await - .unwrap_or_else(|error| { - tracing::error!(%error, "Failed to initialize application"); - process::exit(1); - }); + let (router, _) = subscriptions::init(config.clone()).await?; // Bind address - let listener = TcpListener::bind((config.host.clone(), config.port)) - .await - .unwrap_or_else(|error| { - tracing::error!(%error, "Failed to bind address `{}:{}`", config.host, config.port); - process::exit(1); - }); + let listener = TcpListener::bind((config.host.clone(), config.port)).await?; // Start server tracing::info!("Start listening on: http://{}:{}", config.host, config.port); - if let Err(error) = axum::serve(listener, router).await { - tracing::error!(%error, "Failed to start server"); - process::exit(1); - } + axum::serve(listener, router).await?; tracing::info!("Server gracefully shutdown"); + + Ok(()) } diff --git a/src/model.rs b/src/model.rs index 06774b5..ca3622f 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,4 +1,5 @@ use crate::{Error, Result, config::DatabaseConfig, domain}; +use include_dir::include_dir; use secrecy::ExposeSecret; use surrealdb::{Surreal, engine::any::Any, opt::auth::Database}; use surrealdb_migrations::MigrationRunner; @@ -44,8 +45,30 @@ impl ModelManager { .bind(("token_val", token.to_string())) .bind(("email", subscriber.email.as_ref().to_string())) .bind(("name", subscriber.name.as_ref().to_string())) + .await.map_err(Box::new)? + .check().map_err(Box::new)?; + + Ok(()) + } + + pub async fn confirm_subscriber(&self, token: String) -> Result<()> { + self.db() .await? - .check()?; + .query( + r#" + UPDATE subscriptions + SET status = 'CONFIRMED' + WHERE token = ( + SELECT id + FROM ONLY subscription_tokens + WHERE token = $token_val + ).id + AND status = 'PENDING'; + "#, + ) + .bind(("token_val", token)) + .await + .map_err(Box::new)?; Ok(()) } @@ -55,7 +78,9 @@ impl ModelManager { let db = Surreal::::init(); tracing::info!("Connecting to database: {}", config.base_url.as_str()); - db.connect(config.base_url.as_str()).await?; + db.connect(config.base_url.as_str()) + .await + .map_err(Box::new)?; if config.base_url.scheme() == "mem" { db.query(format!("DEFINE NAMESPACE {}", config.namespace)) @@ -66,7 +91,8 @@ impl ModelManager { config.username, config.password.expose_secret() )) - .await?; + .await + .map_err(Box::new)?; } db.signin(Database { @@ -75,10 +101,12 @@ impl ModelManager { namespace: &config.namespace, database: &config.name, }) - .await?; + .await + .map_err(Box::new)?; // Apply Migrations MigrationRunner::new(&db) + .load_files(&include_dir!("$CARGO_MANIFEST_DIR/surrealdb")) .up() .await .map_err(|e| Error::Migrations(e.to_string()))?; diff --git a/events/.gitkeep b/surrealdb/events/.gitkeep similarity index 100% rename from events/.gitkeep rename to surrealdb/events/.gitkeep diff --git a/migrations/.gitkeep b/surrealdb/migrations/.gitkeep similarity index 100% rename from migrations/.gitkeep rename to surrealdb/migrations/.gitkeep diff --git a/migrations/definitions/_initial.json b/surrealdb/migrations/definitions/_initial.json similarity index 100% rename from migrations/definitions/_initial.json rename to surrealdb/migrations/definitions/_initial.json diff --git a/schemas/script_migration.surql b/surrealdb/schemas/script_migration.surql similarity index 100% rename from schemas/script_migration.surql rename to surrealdb/schemas/script_migration.surql diff --git a/schemas/subscription_tokens.surql b/surrealdb/schemas/subscription_tokens.surql similarity index 100% rename from schemas/subscription_tokens.surql rename to surrealdb/schemas/subscription_tokens.surql diff --git a/schemas/subscriptions.surql b/surrealdb/schemas/subscriptions.surql similarity index 100% rename from schemas/subscriptions.surql rename to surrealdb/schemas/subscriptions.surql