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}