Skip to main content

autumn_web/
db.rs

1//! Database connection pool and extractor.
2//!
3//! This module provides async Postgres connectivity via `diesel-async` with
4//! the `deadpool` connection pool. The pool is created at startup by
5//! [`AppBuilder::run`](crate::app::AppBuilder::run) and stored in
6//! [`AppState`].
7//!
8//! When no `database.url` is configured, [`create_pool`] returns `Ok(None)`
9//! and the application runs without a database -- useful for static-site or
10//! API-gateway use cases.
11//!
12//! # The [`Db`] extractor
13//!
14//! Declare `db: Db` in your handler signature to get a pooled connection.
15//! The connection is automatically returned to the pool when `Db` is dropped
16//! at the end of the request.
17//!
18//! ```rust,no_run
19//! use autumn_web::prelude::*;
20//!
21//! #[get("/hello")]
22//! async fn hello(db: Db) -> AutumnResult<String> {
23//!     // Use `db` with Diesel queries...
24//!     Ok("hello from db".to_string())
25//! }
26//! ```
27
28use axum::extract::FromRequestParts;
29use diesel_async::AsyncPgConnection;
30use diesel_async::pooled_connection::AsyncDieselConnectionManager;
31use diesel_async::pooled_connection::deadpool::Pool;
32
33use crate::AppState;
34use crate::config::DatabaseConfig;
35use crate::error::AutumnError;
36
37/// Error type for pool creation failures.
38///
39/// Alias for the deadpool `BuildError`. Returned by [`create_pool`] when
40/// the pool cannot be constructed (e.g., invalid max-size configuration).
41pub type PoolError = diesel_async::pooled_connection::deadpool::BuildError;
42
43/// Create a connection pool from the database configuration.
44///
45/// Returns `Ok(None)` if no database URL is configured (`database.url` is
46/// absent or `null` in `autumn.toml`).
47///
48/// # Errors
49///
50/// Returns [`PoolError`] if the pool cannot be built (e.g., invalid
51/// max-size configuration).
52pub fn create_pool(config: &DatabaseConfig) -> Result<Option<Pool<AsyncPgConnection>>, PoolError> {
53    let Some(url) = &config.url else {
54        return Ok(None);
55    };
56
57    let manager = AsyncDieselConnectionManager::<AsyncPgConnection>::new(url);
58    let pool = Pool::builder(manager).max_size(config.pool_size).build()?;
59
60    Ok(Some(pool))
61}
62
63// ── Db extractor ─────────────────────────────────────────────
64
65/// Connection type managed by the deadpool pool.
66type PooledConnection = diesel_async::pooled_connection::deadpool::Object<AsyncPgConnection>;
67
68/// Async database connection extractor.
69///
70/// Declare `db: Db` in a handler signature to get a pooled connection to
71/// Postgres. The connection is returned to the pool when `Db` is dropped
72/// at the end of the request.
73///
74/// `Db` implements [`Deref`](std::ops::Deref) and
75/// [`DerefMut`](std::ops::DerefMut) to
76/// `diesel_async::AsyncPgConnection`, so you can use it directly with
77/// Diesel query methods.
78///
79/// If no database is configured (i.e., `database.url` is absent),
80/// requests that use `Db` will receive a `503 Service Unavailable`
81/// response.
82///
83/// # Examples
84///
85/// ```rust,no_run
86/// use autumn_web::prelude::*;
87///
88/// #[get("/ping-db")]
89/// async fn ping_db(db: Db) -> AutumnResult<&'static str> {
90///     // `db` dereferences to AsyncPgConnection
91///     Ok("database is reachable")
92/// }
93/// ```
94pub struct Db(PooledConnection);
95
96impl std::ops::Deref for Db {
97    type Target = AsyncPgConnection;
98    fn deref(&self) -> &Self::Target {
99        &self.0
100    }
101}
102
103impl std::ops::DerefMut for Db {
104    fn deref_mut(&mut self) -> &mut Self::Target {
105        &mut self.0
106    }
107}
108
109impl FromRequestParts<AppState> for Db {
110    type Rejection = AutumnError;
111
112    async fn from_request_parts(
113        _parts: &mut axum::http::request::Parts,
114        state: &AppState,
115    ) -> Result<Self, Self::Rejection> {
116        let pool = state
117            .pool
118            .as_ref()
119            .ok_or_else(|| AutumnError::service_unavailable_msg("Database not configured"))?;
120
121        let conn = pool.get().await.map_err(|e| {
122            tracing::error!("Failed to acquire database connection: {e}");
123            AutumnError::service_unavailable_msg(e.to_string())
124        })?;
125
126        Ok(Self(conn))
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::config::DatabaseConfig;
134
135    // ── Pool creation tests ──────────────────────────────────────
136
137    #[test]
138    fn create_pool_with_no_url_returns_none() {
139        let config = DatabaseConfig::default();
140        let pool = create_pool(&config).expect("should not fail with no URL");
141        assert!(pool.is_none());
142    }
143
144    #[test]
145    fn create_pool_with_url_returns_some() {
146        let config = DatabaseConfig {
147            url: Some("postgres://localhost/test".into()),
148            ..Default::default()
149        };
150        let pool = create_pool(&config).expect("should build pool from valid config");
151        assert!(pool.is_some());
152    }
153
154    #[test]
155    fn pool_respects_max_size() {
156        let config = DatabaseConfig {
157            url: Some("postgres://localhost/test".into()),
158            pool_size: 5,
159            ..Default::default()
160        };
161        let pool = create_pool(&config)
162            .expect("should build pool")
163            .expect("should be Some");
164        assert_eq!(pool.status().max_size, 5);
165    }
166
167    // ── Db extractor tests ───────────────────────────────────────
168
169    #[tokio::test]
170    async fn db_extractor_rejects_when_no_pool() {
171        use axum::Router;
172        use axum::body::Body;
173        use axum::http::{Request, StatusCode};
174        use axum::routing::get;
175        use tower::ServiceExt;
176
177        async fn handler(_db: Db) -> &'static str {
178            "ok"
179        }
180
181        let app = Router::new()
182            .route("/", get(handler))
183            .with_state(AppState { pool: None });
184
185        let response = app
186            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
187            .await
188            .unwrap();
189
190        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
191    }
192}