Skip to main content

dbrest_core/app/
builder.rs

1//! `DbrestApp` — fluent builder for constructing a dbrest router.
2//!
3//! This is the primary API for using dbrest-core as a library. It lets
4//! consumers create an `axum::Router` that can be nested, merged, or
5//! served standalone — instead of handing over the entire server lifecycle.
6//!
7//! # Single datasource
8//!
9//! ```rust,no_run
10//! # async fn example() -> Result<(), dbrest_core::error::Error> {
11//! use std::sync::Arc;
12//! use dbrest_core::app::builder::DbrestApp;
13//! use dbrest_core::config::AppConfig;
14//! // Assume `MyBackend` and `MyDialect` implement the required traits.
15//! # use dbrest_core::backend::{DatabaseBackend, SqlDialect};
16//! # todo!()
17//! # }
18//! ```
19//!
20//! # Multi-datasource (future)
21//!
22//! Register multiple backends keyed by a profile name. The incoming
23//! `Accept-Profile` / `Content-Profile` header selects which datasource
24//! handles the request.
25//!
26//! ```rust,ignore
27//! let app = DbrestApp::new()
28//!     .datasource("pg", pg_backend, PgDialect, pg_config)
29//!     .datasource("sqlite", sqlite_backend, SqliteDialect, sqlite_config)
30//!     .build()
31//!     .await?;
32//! ```
33
34use std::sync::Arc;
35
36use axum::Router;
37
38use crate::backend::{DatabaseBackend, DbVersion, SqlDialect};
39use crate::config::AppConfig;
40use crate::error::Error;
41
42use super::admin::create_admin_router;
43use super::router::create_router;
44use super::state::AppState;
45
46// =========================================================================
47// Datasource — a named (backend, dialect, config) tuple
48// =========================================================================
49
50/// A single database datasource with its backend, dialect, config, and version.
51pub struct Datasource {
52    /// Profile name used for `Accept-Profile` / `Content-Profile` routing.
53    pub name: String,
54    pub backend: Arc<dyn DatabaseBackend>,
55    pub dialect: Arc<dyn SqlDialect>,
56    pub config: AppConfig,
57    pub version: DbVersion,
58}
59
60impl Datasource {
61    /// Create a datasource from its parts.
62    pub fn new(
63        name: impl Into<String>,
64        backend: Arc<dyn DatabaseBackend>,
65        dialect: Arc<dyn SqlDialect>,
66        config: AppConfig,
67        version: DbVersion,
68    ) -> Self {
69        Self {
70            name: name.into(),
71            backend,
72            dialect,
73            config,
74            version,
75        }
76    }
77
78    /// Build an `AppState` and load the schema cache for this datasource.
79    pub async fn into_state(self) -> Result<AppState, Error> {
80        let state =
81            AppState::new_with_backend(self.backend, self.dialect, self.config, self.version);
82        state.reload_schema_cache().await?;
83        Ok(state)
84    }
85}
86
87// =========================================================================
88// DbrestApp builder
89// =========================================================================
90
91/// Fluent builder for constructing a dbrest `axum::Router`.
92///
93/// Use this when you want to embed dbrest in a larger application or
94/// need fine-grained control over the server lifecycle.
95pub struct DbrestApp {
96    datasources: Vec<Datasource>,
97    include_admin: bool,
98    /// Optional path prefix for nesting (e.g. "/api").
99    prefix: Option<String>,
100}
101
102impl DbrestApp {
103    /// Create a new builder with no datasources.
104    pub fn new() -> Self {
105        Self {
106            datasources: Vec::new(),
107            include_admin: false,
108            prefix: None,
109        }
110    }
111
112    /// Add a datasource.
113    pub fn datasource(mut self, ds: Datasource) -> Self {
114        self.datasources.push(ds);
115        self
116    }
117
118    /// Convenience: add a single datasource from its raw parts.
119    pub fn with_backend(
120        self,
121        backend: Arc<dyn DatabaseBackend>,
122        dialect: Arc<dyn SqlDialect>,
123        config: AppConfig,
124        version: DbVersion,
125    ) -> Self {
126        self.datasource(Datasource::new(
127            "default", backend, dialect, config, version,
128        ))
129    }
130
131    /// Include the admin router (health, ready, metrics, config) at `/admin`.
132    pub fn with_admin(mut self) -> Self {
133        self.include_admin = true;
134        self
135    }
136
137    /// Set a path prefix for all dbrest routes (e.g. "/api/v1").
138    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
139        self.prefix = Some(prefix.into());
140        self
141    }
142
143    /// Build the `axum::Router`.
144    ///
145    /// Loads schema caches for all datasources and wires up routing.
146    /// For a single datasource, returns the standard dbrest router.
147    /// For multiple datasources (future), routes by profile header.
148    pub async fn build(self) -> Result<DbrestRouters, Error> {
149        if self.datasources.is_empty() {
150            return Err(Error::InvalidConfig {
151                message: "DbrestApp requires at least one datasource".to_string(),
152            });
153        }
154
155        // Build AppState for the first (or only) datasource
156        // Safety: `self.datasources.is_empty()` is checked above, so this
157        // will always succeed. Using `expect` to be explicit about the
158        // invariant rather than panicking with an opaque unwrap message.
159        let primary = self
160            .datasources
161            .into_iter()
162            .next()
163            .expect("BUG: datasources checked non-empty above");
164        let state = primary.into_state().await?;
165
166        let mut api_router = create_router(state.clone());
167        if let Some(ref prefix) = self.prefix {
168            api_router = Router::new().nest(prefix, api_router);
169        }
170
171        let admin_router = if self.include_admin {
172            Some(create_admin_router(state.clone()))
173        } else {
174            None
175        };
176
177        Ok(DbrestRouters {
178            api: api_router,
179            admin: admin_router,
180            state,
181        })
182    }
183
184    /// Build and return only the API router (convenience for embedding).
185    pub async fn into_router(self) -> Result<Router, Error> {
186        Ok(self.build().await?.api)
187    }
188}
189
190impl Default for DbrestApp {
191    fn default() -> Self {
192        Self::new()
193    }
194}
195
196// =========================================================================
197// DbrestRouters — the build output
198// =========================================================================
199
200/// Output of [`DbrestApp::build`].
201///
202/// Contains the API router, optional admin router, and the `AppState` for
203/// advanced use (e.g. manually starting a NOTIFY listener or reloading
204/// the schema cache).
205pub struct DbrestRouters {
206    /// The main REST API router.
207    pub api: Router,
208    /// Optional admin router (health, metrics, config).
209    pub admin: Option<Router>,
210    /// The constructed application state (for lifecycle management).
211    pub state: AppState,
212}
213
214impl DbrestRouters {
215    /// Merge the API and admin routers into a single router.
216    ///
217    /// Admin routes are nested under `/admin`.
218    pub fn merged(self) -> Router {
219        let mut router = self.api;
220        if let Some(admin) = self.admin {
221            router = router.nest("/admin", admin);
222        }
223        router
224    }
225
226    /// Start the NOTIFY listener in the background (if the backend supports it).
227    ///
228    /// Returns a cancel handle — send `true` to shut down the listener.
229    pub fn start_listener(&self) -> tokio::sync::watch::Sender<bool> {
230        let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
231        let config = self.state.config();
232
233        if config.db_channel_enabled {
234            let db = self.state.db.clone();
235            let state = self.state.clone();
236            let channel = config.db_channel.clone();
237            tokio::spawn(async move {
238                super::server::start_notify_listener_public(db, state, &channel, cancel_rx).await;
239            });
240        }
241
242        cancel_tx
243    }
244}