Skip to main content

siderite_core/
app.rs

1//! The [`App`] builder.
2//!
3//! An `App` is a *description* of an application: routes, mounted child
4//! apps, state, middleware and lifespan hooks. Nothing is materialised until
5//! [`App::run`] or [`App::into_router_service`], which validate the whole
6//! configuration first and report problems as [`ServerError::Configuration`]
7//! instead of panicking.
8//!
9//! # Mounted applications
10//! `app.mount("/api/v1", child)` keeps the child's state and middleware
11//! scoped to the child's routes. Child routes are **merged** into the root
12//! OpenAPI document with the prefix prepended; child docs settings are
13//! ignored. Child lifespan hooks are lifted into the parent (child hooks run
14//! after the parent's at startup, before them at shutdown).
15
16use crate::background::{BackgroundTaskLimits, TaskManager};
17use crate::body::Body;
18use crate::di::{self, DiRegistry};
19use crate::error::{ApiError, ServerError};
20use crate::lifespan::{DEFAULT_SHUTDOWN_BUDGET, Lifespan};
21use crate::response::IntoResponse;
22use crate::routing::{Endpoint, MethodRouter, Route};
23use siderite_openapi::{DocumentBuilder, OpenApi, OpenApiError, ui};
24use siderite_orm::{Databases, Db};
25use std::collections::BTreeMap;
26use std::future::Future;
27use std::pin::Pin;
28use std::sync::Arc;
29
30/// Future type used by lifespan hooks.
31pub(crate) type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
32
33/// Deferred transformation of the internal router (state, middleware,
34/// services). Applied in registration order when the app is built.
35pub(crate) type RouterLayer = Box<dyn FnOnce(axum::Router) -> axum::Router + Send>;
36
37/// A startup or shutdown hook.
38pub(crate) type LifespanHook = Box<dyn FnOnce() -> BoxFuture<Result<(), ApiError>> + Send>;
39
40/// Application metadata used for OpenAPI generation.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct AppMeta {
43    /// API title.
44    pub title: String,
45    /// API version.
46    pub version: String,
47    /// API description.
48    pub description: Option<String>,
49}
50
51impl Default for AppMeta {
52    fn default() -> Self {
53        Self {
54            title: "siderite".to_owned(),
55            version: "0.1.0".to_owned(),
56            description: None,
57        }
58    }
59}
60
61/// Where the generated documentation is served. `None` disables an endpoint.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct DocsConfig {
64    /// OpenAPI JSON document (default `/openapi.json`).
65    pub openapi_url: Option<String>,
66    /// Swagger UI (default `/docs`); requires `openapi_url`.
67    pub swagger_url: Option<String>,
68    /// ReDoc (default `/redoc`); requires `openapi_url`.
69    pub redoc_url: Option<String>,
70}
71
72impl Default for DocsConfig {
73    fn default() -> Self {
74        Self {
75            openapi_url: Some("/openapi.json".to_owned()),
76            swagger_url: Some("/docs".to_owned()),
77            redoc_url: Some("/redoc".to_owned()),
78        }
79    }
80}
81
82/// A child app mounted under a prefix.
83struct Mount {
84    prefix: String,
85    app: App,
86}
87
88/// An HTTP application: routes, state, middleware, lifespan and metadata.
89pub struct App {
90    pub(crate) meta: AppMeta,
91    pub(crate) docs: DocsConfig,
92    pub(crate) routes: Vec<(String, MethodRouter)>,
93    mounts: Vec<Mount>,
94    /// State and middleware layers, applied in registration order.
95    pub(crate) router_layers: Vec<RouterLayer>,
96    /// Services mounted at prefixes (e.g. static files); not documented.
97    pub(crate) services: Vec<RouterLayer>,
98    /// Replaces the default 404 problem fallback (e.g. root static files).
99    pub(crate) fallback: Option<RouterLayer>,
100    pub(crate) startup_hooks: Vec<LifespanHook>,
101    pub(crate) shutdown_hooks: Vec<LifespanHook>,
102    pub(crate) shutdown_budget: std::time::Duration,
103    pub(crate) background_limits: BackgroundTaskLimits,
104    pub(crate) server_limits: crate::ServerLimits,
105    /// Middleware; the first registered is the outermost.
106    pub(crate) middleware: Vec<RouterLayer>,
107    /// Dependency-injection configuration.
108    pub(crate) di: DiRegistry,
109    /// Named databases, exposed to handlers as `State<Databases>`.
110    databases: Option<Databases>,
111    /// Builder misuse reported by [`validate`](Self::validate) instead of
112    /// panicking while the router is built.
113    pub(crate) config_errors: Vec<String>,
114}
115
116impl std::fmt::Debug for App {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        f.debug_struct("App")
119            .field("meta", &self.meta)
120            .field(
121                "routes",
122                &self.routes.iter().map(|(p, _)| p).collect::<Vec<_>>(),
123            )
124            .field(
125                "mounts",
126                &self.mounts.iter().map(|m| &m.prefix).collect::<Vec<_>>(),
127            )
128            .finish_non_exhaustive()
129    }
130}
131
132impl Default for App {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138pub(crate) async fn route_not_found() -> axum::response::Response {
139    ApiError::not_found("No route matches the requested path.")
140        .into_response()
141        .map(Body::into_inner)
142}
143
144impl App {
145    /// Create an empty application.
146    pub fn new() -> Self {
147        Self {
148            meta: AppMeta::default(),
149            docs: DocsConfig::default(),
150            routes: Vec::new(),
151            mounts: Vec::new(),
152            router_layers: Vec::new(),
153            services: Vec::new(),
154            fallback: None,
155            startup_hooks: Vec::new(),
156            shutdown_hooks: Vec::new(),
157            shutdown_budget: DEFAULT_SHUTDOWN_BUDGET,
158            background_limits: BackgroundTaskLimits::default(),
159            server_limits: crate::ServerLimits::default(),
160            middleware: Vec::new(),
161            di: DiRegistry::default(),
162            databases: None,
163            config_errors: Vec::new(),
164        }
165    }
166
167    /// Set the API title.
168    #[must_use]
169    pub fn title(mut self, title: impl Into<String>) -> Self {
170        self.meta.title = title.into();
171        self
172    }
173
174    /// Set the API version.
175    #[must_use]
176    pub fn version(mut self, version: impl Into<String>) -> Self {
177        self.meta.version = version.into();
178        self
179    }
180
181    /// Set the API description.
182    #[must_use]
183    pub fn description(mut self, description: impl Into<String>) -> Self {
184        self.meta.description = Some(description.into());
185        self
186    }
187
188    /// Configure documentation endpoints.
189    #[must_use]
190    pub fn docs(mut self, docs: DocsConfig) -> Self {
191        self.docs = docs;
192        self
193    }
194
195    /// The application metadata.
196    pub fn meta(&self) -> &AppMeta {
197        &self.meta
198    }
199
200    /// Register `methods` at `path` (use `{param}` for path parameters).
201    #[must_use]
202    pub fn route(mut self, path: &str, methods: MethodRouter) -> Self {
203        self.routes.push((path.to_owned(), methods));
204        self
205    }
206
207    /// Register routes produced by route macros (see `routes![]`).
208    #[must_use]
209    pub fn routes(mut self, routes: impl IntoIterator<Item = Route>) -> Self {
210        for route in routes {
211            self = self.route(route.path, route.router);
212        }
213        self
214    }
215
216    /// Mount `app` under `prefix` (e.g. `/api/v1`); `/` merges its routes
217    /// into this app. See the module docs.
218    #[must_use]
219    pub fn mount(mut self, prefix: &str, app: App) -> Self {
220        self.mounts.push(Mount {
221            prefix: prefix.trim_end_matches('/').to_owned(),
222            app,
223        });
224        self
225    }
226
227    /// Alias of [`App::mount`].
228    #[must_use]
229    pub fn nest(self, prefix: &str, app: App) -> Self {
230        self.mount(prefix, app)
231    }
232
233    /// Make `state` available to handlers through [`State<T>`](crate::State).
234    ///
235    /// Applies to every route of this app regardless of call order.
236    #[must_use]
237    pub fn with_state<T: Send + Sync + 'static>(mut self, state: T) -> Self {
238        let state = Arc::new(state);
239        self.router_layers
240            .push(Box::new(move |router: axum::Router| {
241                router.layer(axum::Extension(state))
242            }));
243        self
244    }
245
246    /// Register `db` under `alias` (`"default"`, `"replica"`, ...).
247    ///
248    /// Every registered database is available to handlers as
249    /// `State<Databases>`; a router set with [`App::databases`] is kept.
250    #[must_use]
251    pub fn database(mut self, alias: impl Into<String>, db: Db) -> Self {
252        let databases = self.databases.take().unwrap_or_default();
253        self.databases = Some(databases.with(alias, db));
254        self
255    }
256
257    /// Replace the whole database registry (aliases and router).
258    #[must_use]
259    pub fn databases(mut self, databases: Databases) -> Self {
260        self.databases = Some(databases);
261        self
262    }
263
264    /// The registered databases, if any.
265    pub fn database_registry(&self) -> Option<&Databases> {
266        self.databases.as_ref()
267    }
268
269    /// Every documented endpoint as `(full path, endpoint)`, including mounts.
270    fn endpoints(&self) -> Vec<(String, &Endpoint)> {
271        let mut out: Vec<(String, &Endpoint)> = self
272            .routes
273            .iter()
274            .flat_map(|(path, r)| r.endpoints.iter().map(move |e| (path.clone(), e)))
275            .collect();
276        for mount in &self.mounts {
277            for (path, e) in mount.app.endpoints() {
278                let full = if path == "/" && !mount.prefix.is_empty() {
279                    mount.prefix.clone()
280                } else {
281                    format!("{}{path}", mount.prefix)
282                };
283                out.push((full, e));
284            }
285        }
286        out
287    }
288
289    /// Generate the OpenAPI 3.1 document for this app and its mounts.
290    ///
291    /// # Errors
292    /// Returns [`OpenApiError`] on duplicate operations/ids or schema name
293    /// conflicts.
294    pub fn openapi(&self) -> Result<OpenApi, OpenApiError> {
295        let mut builder = DocumentBuilder::new(&self.meta.title, &self.meta.version);
296        if let Some(d) = &self.meta.description {
297            builder = builder.description(d);
298        }
299        for (path, endpoint) in self.endpoints() {
300            if endpoint.meta.hidden {
301                continue;
302            }
303            if let Some(method) = endpoint.openapi_method() {
304                let op = endpoint.operation(builder.registry());
305                builder.add_operation(&path, method, op);
306            }
307        }
308        builder.build()
309    }
310
311    /// The first builder error recorded by this app or a mounted one.
312    fn first_config_error(&self) -> Option<&str> {
313        self.config_errors
314            .first()
315            .map(String::as_str)
316            .or_else(|| self.mounts.iter().find_map(|m| m.app.first_config_error()))
317    }
318
319    /// Validate route paths and reject duplicate `(path, method)` pairs.
320    fn validate(&self) -> Result<(), ServerError> {
321        let mut seen: BTreeMap<(String, String), ()> = BTreeMap::new();
322        for (path, e) in self.endpoints() {
323            if !path.starts_with('/') {
324                return Err(ServerError::Configuration(format!(
325                    "route path `{path}` must start with `/`"
326                )));
327            }
328            if e.openapi_method().is_none() {
329                return Err(ServerError::Configuration(format!(
330                    "unsupported HTTP method {} for route {path}",
331                    e.method
332                )));
333            }
334            if seen
335                .insert((path.clone(), e.method.to_string()), ())
336                .is_some()
337            {
338                return Err(ServerError::Configuration(format!(
339                    "duplicate route {} {path}",
340                    e.method
341                )));
342            }
343        }
344        for (name, url) in [
345            ("openapi_url", &self.docs.openapi_url),
346            ("swagger_url", &self.docs.swagger_url),
347            ("redoc_url", &self.docs.redoc_url),
348        ] {
349            if let Some(url) = url
350                && !url.starts_with('/')
351            {
352                return Err(ServerError::Configuration(format!(
353                    "docs `{name}` `{url}` must start with `/`"
354                )));
355            }
356        }
357        if let Some(message) = self.first_config_error() {
358            return Err(ServerError::Configuration(message.to_owned()));
359        }
360        for m in &self.mounts {
361            // An empty prefix is a root mount (`mount("/", ..)`); its routes
362            // merge into this app, which keeps the fallback.
363            if m.prefix.is_empty() && m.app.fallback.is_some() {
364                return Err(ServerError::Configuration(
365                    "an app mounted at `/` cannot serve root static files; \
366                     call `static_files(\"/\", ..)` on the parent app"
367                        .to_owned(),
368                ));
369            }
370            if !m.prefix.is_empty() && !m.prefix.starts_with('/') {
371                return Err(ServerError::Configuration(format!(
372                    "mount prefix `{}` must start with `/`",
373                    m.prefix
374                )));
375            }
376        }
377        Ok(())
378    }
379
380    /// Build the internal router; lifts lifespan hooks out of mounts.
381    ///
382    /// Layering, innermost first: routes, mounts, services, DI scope, state
383    /// and other router layers. The app's middleware is returned separately so
384    /// the caller applies it once everything else (docs, fallback) exists.
385    fn build_router(
386        mut self,
387        hooks: &mut Lifespan,
388        root: bool,
389    ) -> (axum::Router, Vec<RouterLayer>, Option<RouterLayer>) {
390        hooks.startup.append(&mut self.startup_hooks);
391        // Own shutdown hooks come before the children's in the list; shutdown runs
392        // the list reversed, so children stop first, mirroring startup.
393        hooks.shutdown.append(&mut self.shutdown_hooks);
394
395        let has_background = self.routes.iter().any(|(_, methods)| {
396            methods
397                .endpoints
398                .iter()
399                .any(|endpoint| endpoint.background_tasks)
400        });
401        let background = has_background.then(|| {
402            let manager = TaskManager::new(self.background_limits);
403            hooks.background.push(manager.clone());
404            manager
405        });
406        let mut grouped: BTreeMap<String, Vec<Endpoint>> = BTreeMap::new();
407        for (path, r) in self.routes {
408            grouped.entry(path).or_default().extend(r.endpoints);
409        }
410        let mut router = axum::Router::new();
411        for (path, endpoints) in grouped {
412            let method_router = endpoints.into_iter().filter_map(Endpoint::into_axum).fold(
413                axum::routing::MethodRouter::new(),
414                axum::routing::MethodRouter::merge,
415            );
416            router = router.route(&path, method_router);
417        }
418        for mount in self.mounts {
419            let (child, middleware, _) = mount.app.build_router(hooks, false);
420            let child = apply_middleware(child, middleware);
421            router = if mount.prefix.is_empty() {
422                router.merge(child)
423            } else {
424                router.nest(&mount.prefix, child)
425            };
426        }
427        for service in self.services {
428            router = service(router);
429        }
430        // The root app installs its fallback in `build`, after the docs routes.
431        let mut fallback = self.fallback;
432        if !root && let Some(f) = fallback.take() {
433            router = f(router);
434        }
435        if root || !self.di.is_empty() {
436            router = di::install(router, self.di);
437        }
438        router = self
439            .router_layers
440            .into_iter()
441            .fold(router, |r, layer| layer(r));
442        if let Some(manager) = background {
443            router = router.layer(axum::Extension(manager));
444        }
445        (router, self.middleware, fallback)
446    }
447
448    /// Validate and build the root router, including documentation routes.
449    pub(crate) fn build(mut self) -> Result<(axum::Router, Lifespan), ServerError> {
450        self.validate()?;
451        if let Some(databases) = self.databases.take() {
452            self = self.with_state(databases);
453        }
454        let openapi = self
455            .openapi()
456            .map_err(|e| ServerError::Configuration(e.to_string()))?;
457        let docs = self.docs.clone();
458        let title = self.meta.title.clone();
459        let upgrades = crate::server::tasks::TaskOwner::new(self.server_limits.max_websockets);
460        let mut hooks = Lifespan {
461            shutdown_budget: self.shutdown_budget,
462            server: Some(crate::server::ServerOwners::new(self.server_limits)),
463            ..Lifespan::default()
464        };
465        let (mut router, middleware, fallback) = self.build_router(&mut hooks, true);
466        if let Some(spec_url) = docs.openapi_url {
467            let json = serde_json::to_string(&openapi)
468                .map_err(|e| ServerError::Configuration(e.to_string()))?;
469            // Docs routes sit outside the DI layer, which records the matched
470            // route for request logging, so they record it themselves.
471            let mut docs_router =
472                axum::Router::new().route(&spec_url, static_route("application/json", json));
473            if let Some(url) = docs.swagger_url {
474                let html = ui::swagger_ui_html(&title, &spec_url);
475                docs_router =
476                    docs_router.route(&url, static_route("text/html; charset=utf-8", html));
477            }
478            if let Some(url) = docs.redoc_url {
479                let html = ui::redoc_html(&title, &spec_url);
480                docs_router =
481                    docs_router.route(&url, static_route("text/html; charset=utf-8", html));
482            }
483            router = router.merge(docs_router.route_layer(axum::middleware::from_fn(
484                |req: axum::extract::Request, next: axum::middleware::Next| {
485                    let (parts, body) = req.into_parts();
486                    crate::middleware::note_matched_path(&parts);
487                    next.run(axum::extract::Request::from_parts(parts, body))
488                },
489            )));
490        }
491        let router = match fallback {
492            Some(install) => install(router),
493            None => router.fallback(route_not_found),
494        };
495        let router = apply_middleware(router, middleware);
496        let router = router.layer(axum::Extension(upgrades.handle()));
497        let router = if let Some(server) = &hooks.server {
498            router.layer(axum::Extension(server.readiness.clone()))
499        } else {
500            router
501        };
502        hooks.transports.push(upgrades);
503        Ok((router, hooks))
504    }
505
506    /// Convert into an in-process `tower::Service` (used by the testkit).
507    /// Lifespan hooks are **not** run.
508    ///
509    /// # Errors
510    /// Returns [`ServerError::Configuration`] if the app is misconfigured.
511    pub fn into_router_service(self) -> Result<crate::RouterService, ServerError> {
512        self.build()
513            .map(|(router, _)| crate::RouterService::new(router))
514    }
515
516    /// Like [`App::into_router_service`], but also returns the [`Lifespan`]
517    /// so the caller can run startup and shutdown hooks around the service
518    /// without binding a socket (used by the testkit).
519    ///
520    /// # Errors
521    /// Returns [`ServerError::Configuration`] if the app is misconfigured.
522    pub fn into_service_with_lifespan(
523        self,
524    ) -> Result<(crate::RouterService, Lifespan), ServerError> {
525        self.build()
526            .map(|(router, lifespan)| (crate::RouterService::new(router), lifespan))
527    }
528
529    /// Serve until Ctrl-C or Unix SIGTERM, then drain and clean up resources.
530    ///
531    /// Args:
532    ///     addr: Address to bind.
533    ///
534    /// Returns:
535    ///     Success after shutdown and resource cleanup.
536    ///
537    /// # Errors
538    /// Configuration, startup, bind, serving or shutdown errors.
539    pub async fn run(self, addr: &str) -> Result<(), ServerError> {
540        self.run_until(addr, shutdown_signal()).await
541    }
542
543    /// Serve until a caller-supplied shutdown future completes.
544    ///
545    /// Startup cancellation and bind failure clean initialized resources.
546    /// HTTP draining and teardown share one deadline. Connection, stream
547    /// and WebSocket workers are cancelled before resource teardown.
548    ///
549    /// Args:
550    ///     addr: Address to bind.
551    ///     shutdown: Future requesting graceful termination.
552    ///
553    /// Returns:
554    ///     Success after serving and cleanup finish.
555    ///
556    /// # Errors
557    /// Configuration/startup/I/O errors or exceeded shutdown deadline.
558    pub async fn run_until<F>(self, addr: &str, shutdown: F) -> Result<(), ServerError>
559    where
560        F: Future<Output = ()> + Send + 'static,
561    {
562        let budget = self.shutdown_budget;
563        let limits = self.server_limits;
564        let (router, lifespan) = self.build()?;
565        let server = lifespan
566            .server
567            .clone()
568            .ok_or_else(|| ServerError::Configuration("missing server owner".to_owned()))?;
569        let mut owner = lifespan.supervise()?;
570        let mut shutdown = Box::pin(shutdown);
571        tokio::select! {
572            result = owner.ready() => result?,
573            _ = &mut shutdown => return owner.shutdown().await,
574        }
575        let listener = match tokio::net::TcpListener::bind(addr).await {
576            Ok(listener) => listener,
577            Err(source) => {
578                let _ = owner.shutdown().await;
579                return Err(ServerError::Bind {
580                    addr: addr.to_owned(),
581                    source,
582                });
583            }
584        };
585        tracing::info!(%addr, "listening");
586        let serving = crate::server::serve(listener, router, limits, server, budget, shutdown);
587        let (result, deadline) = serving.await;
588        let stopped = owner.shutdown_at(deadline).await;
589        result?;
590        stopped
591    }
592}
593
594/// Apply middleware so that the first registered layer is the outermost.
595fn apply_middleware(router: axum::Router, layers: Vec<RouterLayer>) -> axum::Router {
596    layers
597        .into_iter()
598        .rev()
599        .fold(router, |router, layer| layer(router))
600}
601
602fn static_route(content_type: &'static str, body: String) -> axum::routing::MethodRouter {
603    let body: Arc<str> = body.into();
604    axum::routing::get(move || {
605        let body = Arc::clone(&body);
606        async move {
607            let mut resp = axum::response::Response::new(axum::body::Body::from(body.to_string()));
608            resp.headers_mut().insert(
609                http::header::CONTENT_TYPE,
610                http::HeaderValue::from_static(content_type),
611            );
612            resp
613        }
614    })
615}
616
617async fn shutdown_signal() {
618    let interrupt = async {
619        if let Err(error) = tokio::signal::ctrl_c().await {
620            tracing::error!(%error, "cannot listen for Ctrl-C");
621            std::future::pending::<()>().await;
622        }
623    };
624    #[cfg(unix)]
625    let terminate = async {
626        match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
627            Ok(mut signal) => {
628                signal.recv().await;
629            }
630            Err(error) => {
631                tracing::error!(%error, "cannot listen for SIGTERM");
632                std::future::pending::<()>().await;
633            }
634        }
635    };
636    #[cfg(not(unix))]
637    let terminate = std::future::pending::<()>();
638    tokio::select! { _ = interrupt => {}, _ = terminate => {} }
639}