Skip to main content

umbral_core/
app.rs

1use axum::Router;
2use std::collections::HashMap;
3use std::net::SocketAddr;
4
5use crate::db::{self, DbPool};
6use crate::migrate::ModelMeta;
7use crate::orm::Model;
8use crate::plugin::Plugin;
9use crate::settings::Settings;
10
11/// A per-request resolver that builds the request-scoped
12/// [`crate::db::RouteContext`] from the incoming request. Installed via
13/// [`AppBuilder::route_context`] and driven by [`route_context_scope_layer`].
14type RouteContextResolver =
15    std::sync::Arc<dyn Fn(&crate::web::Request) -> crate::db::RouteContext + Send + Sync>;
16
17/// A built and ready-to-serve umbral application.
18///
19/// Created via `App::builder().build()`. Owns the merged router that
20/// carries every registered plugin's routes plus the user-binary
21/// routes passed to `AppBuilder::routes()`.
22pub struct App {
23    router: Router,
24    plugins: Vec<Box<dyn Plugin>>,
25}
26
27impl App {
28    /// Create a new [`AppBuilder`].
29    pub fn builder() -> AppBuilder {
30        // Load `.env` into the *process* environment so plain
31        // `std::env::var(...)` code sees it — most importantly a plugin's
32        // `from_env()` credential loader (e.g. the OAuth providers reading
33        // `UMBRAL_OAUTH_*`). This runs before the `.plugin(...)` arguments
34        // are evaluated, so those loaders find the values.
35        //
36        // We read `.env` the *same* CWD-relative way figment's settings
37        // loader does (`from_filename_iter(".env")`) rather than
38        // `dotenvy::dotenv()`, whose parent-directory search resolves the
39        // file differently and missed it in practice. Each key is set only
40        // when it isn't already present, so real environment vars keep
41        // precedence. No-op when there's no `.env`.
42        if let Ok(iter) = dotenvy::from_filename_iter(".env") {
43            for (key, value) in iter.flatten() {
44                if std::env::var_os(&key).is_none() {
45                    // SAFETY: runs at startup (App::builder), before the
46                    // server spawns request handlers that read the
47                    // environment — the same operation `dotenvy::dotenv()`
48                    // performs internally.
49                    unsafe { std::env::set_var(&key, &value) };
50                }
51            }
52        }
53        AppBuilder::default()
54    }
55
56    /// Bind the axum listener and serve requests.
57    ///
58    /// This call blocks until the server stops. At M0 there is no graceful
59    /// shutdown hook; that lands with the signal-handling work in a later
60    /// milestone.
61    pub async fn serve(self, addr: impl Into<SocketAddr>) -> Result<(), std::io::Error> {
62        let listener = tokio::net::TcpListener::bind(addr.into()).await?;
63
64        tracing::info!("umbral serving on {}", listener.local_addr()?);
65
66        // Serve via `into_make_service()` rather than passing the router
67        // directly. `axum::serve(listener, router)` drives the `Router` as
68        // its own connection-maker, whose per-connection `call` runs
69        // `self.clone().with_state(())` — and `with_state` finalizes EVERY
70        // route eagerly, an O(route-count) cost paid once per new TCP
71        // connection. With keep-alive that's amortized over all requests on
72        // the connection; WITHOUT keep-alive (one connection per request) it
73        // is paid on every request, capping throughput at ~1/with_state-cost
74        // regardless of the handler. For an app with hundreds of routes (a
75        // full admin + REST surface) that throttled no-keep-alive throughput
76        // by ~4x or worse. `IntoMakeService` instead hands each connection a
77        // cheap `Router::clone()` (an `Arc` bump) and lets routing finalize
78        // lazily per request — measurably faster on fresh connections and no
79        // slower with keep-alive. No `ConnectInfo` regression: the direct
80        // path didn't provide it either (that needs
81        // `into_make_service_with_connect_info`).
82        axum::serve(listener, self.router.into_make_service()).await
83    }
84
85    /// Consume the [`App`] and return its merged axum router.
86    ///
87    /// Useful when the caller wants to drive the router themselves: an
88    /// integration test that sends synthetic requests via
89    /// `tower::ServiceExt::oneshot`, an embedding scenario that nests
90    /// umbral under another axum tree, or any other path that doesn't
91    /// want `serve()`'s opinionated listener.
92    pub fn into_router(self) -> Router {
93        self.router
94    }
95
96    /// Borrow the registered plugins in topological dependency order.
97    ///
98    /// Used by [`crate::cli::dispatch`] to walk every plugin's
99    /// `commands()` contribution at CLI dispatch time. Borrowed (not
100    /// moved) so the App stays usable after a dispatch call returns.
101    pub fn plugins(&self) -> &[Box<dyn Plugin>] {
102        &self.plugins
103    }
104}
105
106/// The fluent entry point for constructing an [`App`].
107///
108/// Collects settings, database pools, and routes, then locks everything
109/// into place at [`build`](AppBuilder::build).
110pub struct AppBuilder {
111    settings: Option<Settings>,
112    databases: HashMap<String, DbPool>,
113    router: Option<Router>,
114    /// Companion path list for `router` — surfaces the user's hand-
115    /// registered routes in the dev-mode 404 page. The builder can't
116    /// peek inside an axum `Router`, so the caller declares its paths
117    /// here. Empty by default; production deployments don't need to
118    /// fill it.
119    route_paths: Vec<crate::routes::RouteSpec>,
120    models: Vec<ModelMeta>,
121    plugins: Vec<Box<dyn Plugin>>,
122    templates_dir: Option<std::path::PathBuf>,
123    slash_redirect: crate::slash::SlashRedirect,
124    not_found_template: Option<String>,
125    server_error_template: Option<String>,
126    /// Custom template per status code for general error pages (429, 403, …),
127    /// styled like the 404/500 pages. See [`Self::error_template`].
128    error_templates: HashMap<axum::http::StatusCode, String>,
129    /// Optional hook called before the 500 template is rendered.
130    server_error_hook: Option<crate::errors::ServerErrorHook>,
131    /// When `true` (the default), the embedded default 404/500 templates
132    /// are used as fallbacks when the user hasn't supplied their own.
133    default_error_pages: bool,
134    /// Path-scoped cross-origin policies (prefix → config), applied via
135    /// [`AppBuilder::cors_for`]. Each is layered only onto requests whose
136    /// path starts with the prefix (e.g. `"/api"`).
137    cors_scoped: Vec<(String, crate::cors::CorsConfig)>,
138    /// Optional cross-origin policy. `None` means no `CorsLayer`
139    /// is installed at all and browsers apply the same-origin
140    /// default. Configure via [`AppBuilder::cors`].
141    cors: Option<crate::cors::CorsConfig>,
142    /// When `Some(true)`, every ORM write terminal that supports
143    /// `.atomic()` / `.non_atomic()` runs inside a transaction by
144    /// default. Per-call `.non_atomic()` overrides. `None` keeps the
145    /// pre-flag behaviour (no auto-wrapping). See
146    /// [`AppBuilder::atomic_transactions`].
147    atomic_transactions: Option<bool>,
148    /// When `true`, a `tower-http` gzip/brotli compression layer wraps the
149    /// router. Off by default — a reverse proxy usually owns compression,
150    /// and double-compressing behind one is wasteful. Enable via
151    /// [`AppBuilder::compression`].
152    compress: bool,
153    /// App-level framework middleware (feature #68), prepended to the
154    /// plugins' contributions in the final stack. Added via
155    /// [`AppBuilder::middleware`].
156    middleware: Vec<std::sync::Arc<dyn crate::middleware::Middleware>>,
157    /// Optional custom [`crate::db::DatabaseRouter`]. `None` uses
158    /// `DefaultRouter` (today's static per-model routing). Installed
159    /// during `build()` via [`crate::db::router::install_router`].
160    db_router: Option<std::sync::Arc<dyn crate::db::DatabaseRouter>>,
161    /// Optional per-request resolver that builds the request-scoped
162    /// [`crate::db::RouteContext`]. When set, `build()` installs a layer that
163    /// runs the resolver on each request and scopes the ENTIRE downstream
164    /// future (handler plus every `.await`, including ORM calls) inside
165    /// [`crate::db::route_context::scope`], so the ambient
166    /// `umbral::db::route_context()` accessor — and thus the `DatabaseRouter`
167    /// — sees the context this resolver set. Added via
168    /// [`AppBuilder::route_context`].
169    route_context_resolver: Option<RouteContextResolver>,
170}
171
172impl Default for AppBuilder {
173    fn default() -> Self {
174        Self {
175            settings: None,
176            databases: HashMap::new(),
177            router: None,
178            route_paths: Vec::new(),
179            models: Vec::new(),
180            plugins: Vec::new(),
181            templates_dir: None,
182            slash_redirect: crate::slash::SlashRedirect::default(),
183            not_found_template: None,
184            server_error_template: None,
185            error_templates: HashMap::new(),
186            server_error_hook: None,
187            default_error_pages: true,
188            cors: None,
189            cors_scoped: Vec::new(),
190            atomic_transactions: None,
191            compress: false,
192            middleware: Vec::new(),
193            db_router: None,
194            route_context_resolver: None,
195        }
196    }
197}
198
199impl AppBuilder {
200    /// Set the application settings.
201    pub fn settings(mut self, settings: Settings) -> Self {
202        self.settings = Some(settings);
203        self
204    }
205
206    /// Register a database pool under the given alias.
207    ///
208    /// The `"default"` pool is the one returned by `umbral::db::pool()`
209    /// and is required: `build()` fails with `BuildError::
210    /// DefaultPoolMissing` if it isn't registered. The caller opens
211    /// the pool via `umbral::db::connect(&url).await` and passes it
212    /// here.
213    ///
214    /// Accepts anything that converts into a [`DbPool`]: a typed
215    /// [`sqlx::SqlitePool`], a typed [`sqlx::PgPool`], or an already-
216    /// built `DbPool`. The [`From`] impls on `DbPool` make plain
217    /// SqlitePool callers (every test, every plugin example) work
218    /// unchanged.
219    pub fn database(mut self, alias: &str, pool: impl Into<DbPool>) -> Self {
220        self.databases.insert(alias.to_owned(), pool.into());
221        self
222    }
223
224    /// Install a custom [`crate::db::DatabaseRouter`]. Omit to use
225    /// `DefaultRouter` (today's static per-model routing).
226    pub fn router<R: crate::db::DatabaseRouter + 'static>(mut self, router: R) -> Self {
227        self.db_router = Some(std::sync::Arc::new(router));
228        self
229    }
230
231    /// Install a per-request [`crate::db::RouteContext`] resolver.
232    ///
233    /// The resolver runs once per request, builds a `RouteContext` (typically
234    /// reading a tenant header or subdomain), and `build()` wraps the entire
235    /// downstream future in [`crate::db::route_context::scope`]. Because the
236    /// scope spans the whole handler — including every `.await` and every ORM
237    /// call — the ambient `umbral::db::route_context()` accessor inside the
238    /// handler, and the active [`crate::db::DatabaseRouter`], see exactly the
239    /// context this resolver returned. A request the resolver maps to a
240    /// default `RouteContext` runs with no tenant (no silent inheritance from
241    /// a prior request).
242    ///
243    /// ```ignore
244    /// use umbral::prelude::*;
245    /// use umbral::db::{RouteContext, TenantKey};
246    ///
247    /// App::builder()
248    ///     .route_context(|req| match req.headers().get("x-tenant") {
249    ///         Some(v) => RouteContext::new()
250    ///             .with_tenant(TenantKey::new(v.to_str().unwrap_or_default())),
251    ///         None => RouteContext::new(),
252    ///     })
253    ///     .build()?;
254    /// ```
255    pub fn route_context<F>(mut self, resolver: F) -> Self
256    where
257        F: Fn(&crate::web::Request) -> crate::db::RouteContext + Send + Sync + 'static,
258    {
259        self.route_context_resolver = Some(std::sync::Arc::new(resolver));
260        self
261    }
262
263    /// Register a model with the app's migration engine.
264    ///
265    /// Called once per model the user wants the M5 `makemigrations` /
266    /// `migrate` commands to track. Captures the model's `NAME` /
267    /// `TABLE` / `FIELDS` constants into an owned `ModelMeta` so the
268    /// migration code can iterate without naming concrete `T` at the
269    /// call site. M7's Plugin contract will replace this with
270    /// `Plugin::models()` discovered through the plugin registry.
271    pub fn model<T: Model>(mut self) -> Self {
272        self.models.push(ModelMeta::for_::<T>());
273        self
274    }
275
276    /// Register a plugin (M7).
277    ///
278    /// Plugins contribute models, routes, system_checks, and an
279    /// `on_ready` hook. `App::build()` topologically sorts the
280    /// registered set by `Plugin::dependencies()` and walks every
281    /// plugin's contributions. The plugin name `"app"` is reserved
282    /// for the implicit plugin that owns models registered via
283    /// `.model::<T>()`; a plugin claiming that name causes
284    /// `BuildError::ReservedPluginName`.
285    pub fn plugin<P: Plugin>(mut self, plugin: P) -> Self {
286        self.plugins.push(Box::new(plugin));
287        self
288    }
289
290    /// Attach a [`Routes`](crate::routes::Routes) bundle of
291    /// hand-registered routes.
292    ///
293    /// Each `.get(...) / .post(...) / .put(...) / .patch(...) /
294    /// .delete(...) / .head(...) / .options(...)` call on `Routes`
295    /// records the path *and* registers the handler, so the framework
296    /// surfaces declared routes in the dev-mode 404 page without a
297    /// parallel declaration list.
298    ///
299    /// Multi-method routes go through [`Routes::route`] (explicit
300    /// method list + `axum::routing::MethodRouter`). Routes that need
301    /// axum features the per-method shorthands don't expose (typed
302    /// `State`, middleware layers, `nest`, fallback handlers, etc.)
303    /// go through [`Routes::with_router`] — that escape hatch merges
304    /// an external `axum::Router` and its paths stay opaque to the
305    /// framework (won't appear in the dev 404 page).
306    ///
307    /// Calling this more than once merges the router and concatenates
308    /// the specs.
309    ///
310    /// ```ignore
311    /// use umbral::prelude::*;
312    ///
313    /// App::builder()
314    ///     .routes(
315    ///         Routes::new()
316    ///             .get("/", home)
317    ///             .get("/articles", list_articles_html)
318    ///             .post("/api/articles", create_article),
319    ///     )
320    ///     .build()?;
321    /// ```
322    pub fn routes(mut self, routes: crate::routes::Routes) -> Self {
323        let (router, specs) = routes.into_parts();
324        self.router = Some(match self.router.take() {
325            Some(prior) => prior.merge(router),
326            None => router,
327        });
328        self.route_paths.extend(specs);
329        self
330    }
331
332    /// Set the project-level templates directory.
333    ///
334    /// Defaults to `./templates` (relative to the binary's cwd) when
335    /// the builder method isn't called. If the resolved path doesn't
336    /// exist, the engine still publishes — calls to
337    /// `umbral::templates::render` then return `TemplateError::Missing`
338    /// with a clear diagnostic, which matches the "absence isn't an
339    /// error unless something tries to render" rule from the spec.
340    ///
341    /// This directory is searched first (highest priority). Plugin
342    /// directories contributed via `Plugin::templates_dirs()` are
343    /// appended in topological order and searched afterwards. To
344    /// override a plugin's template, drop a same-named file here.
345    pub fn templates_dir<P: Into<std::path::PathBuf>>(mut self, path: P) -> Self {
346        self.templates_dir = Some(path.into());
347        self
348    }
349
350    /// Set the trailing-slash redirect policy. See
351    /// [`crate::slash::SlashRedirect`].
352    ///
353    /// Default is `Off` (axum's strict matching). Most apps want
354    /// `Append` (`/foo` 404 → 308 → `/foo/`) so that
355    /// the same URL works with or without the trailing slash.
356    ///
357    /// ```ignore
358    /// use umbral::prelude::*;
359    /// use umbral::web::SlashRedirect;
360    ///
361    /// App::builder()
362    ///     .slash_redirect(SlashRedirect::Append)
363    ///     .build()?;
364    /// ```
365    pub fn slash_redirect(mut self, policy: crate::slash::SlashRedirect) -> Self {
366        self.slash_redirect = policy;
367        self
368    }
369
370    /// Set the template rendered on a 404. Follows the
371    /// `404.html` convention.
372    ///
373    /// The template gets `{ path }` in scope — the request path that
374    /// missed — so you can render `The page {{ path }} doesn't
375    /// exist.` without wiring extractors. When unset, 404s return
376    /// plain-text "Not Found". When set but the template fails to
377    /// render (missing file, parse error), the framework falls back
378    /// to the plain-text response and logs the render error.
379    ///
380    /// Composes with [`Self::slash_redirect`] — if a slash-redirect
381    /// probe finds the alternate, it 308s before the not-found
382    /// template fires.
383    pub fn not_found_template(mut self, name: impl Into<String>) -> Self {
384        self.not_found_template = Some(name.into());
385        self
386    }
387
388    /// Set the template rendered on a panicking handler. Follows
389    /// the `500.html` convention.
390    ///
391    /// Installs a `tower-http` `CatchPanic` layer around the router.
392    /// A panic in any handler is caught, logged via `tracing::error`,
393    /// and replaced with a 500 response carrying the rendered
394    /// template. When unset, panics use tower-http's default
395    /// behaviour (log + empty 500 body).
396    ///
397    /// In dev mode (`settings.environment == Dev`), the template receives
398    /// `dev_mode`, `error_display`, `error_chain`, and `request_path`
399    /// context variables. In prod those variables are empty.
400    ///
401    /// See [`Self::on_server_error`] for a hook that fires before the
402    /// template renders.
403    pub fn server_error_template(mut self, name: impl Into<String>) -> Self {
404        self.server_error_template = Some(name.into());
405        self
406    }
407
408    /// Register a custom template for error responses with `status` (e.g.
409    /// `429`, `403`, `410`). When a handler returns `Err((status, message))`
410    /// (or any non-HTML error response with this status), the template is
411    /// rendered in its place — styled like the 404/500 pages — preserving the
412    /// status code. The template receives `{ status, status_text, message,
413    /// request_path, dev_mode }`. Repeatable for multiple codes.
414    ///
415    /// 404 and 500 have dedicated methods ([`Self::not_found_template`] /
416    /// [`Self::server_error_template`]); use this for everything else.
417    ///
418    /// ```ignore
419    /// App::builder()
420    ///     .error_template(StatusCode::TOO_MANY_REQUESTS, "errors/429.html")
421    ///     .error_template(StatusCode::FORBIDDEN, "errors/403.html")
422    /// ```
423    pub fn error_template(
424        mut self,
425        status: axum::http::StatusCode,
426        name: impl Into<String>,
427    ) -> Self {
428        self.error_templates.insert(status, name.into());
429        self
430    }
431
432    /// Register a hook that fires on every internal server error (500).
433    ///
434    /// The closure receives:
435    /// - `error_display: &str` — the `Display` form of the error or the
436    ///   stringified panic payload.
437    /// - `request_path: &str` — the URI path of the failing request (empty
438    ///   for panic-path errors where path isn't yet available).
439    ///
440    /// The hook runs synchronously before the 500 template is rendered. It
441    /// cannot change the response — use it to log to an external service
442    /// (Sentry, Datadog, a file, etc.).
443    ///
444    /// ```ignore
445    /// App::builder()
446    ///     .on_server_error(|err, path| {
447    ///         tracing::error!(err, path, "500 error");
448    ///     })
449    ///     .build()?
450    /// ```
451    pub fn on_server_error<F>(mut self, hook: F) -> Self
452    where
453        F: Fn(&str, &str) + Send + Sync + 'static,
454    {
455        self.server_error_hook = Some(std::sync::Arc::new(hook));
456        self
457    }
458
459    /// Disable the built-in default 404/500 templates.
460    ///
461    /// By default, when the user hasn't called `.not_found_template(...)` or
462    /// `.server_error_template(...)`, umbral renders its own embedded Tailwind
463    /// error pages. Call this method to revert to axum's built-in behaviour:
464    /// a plain-text "Not Found" on 404 and an empty 500 body on panic.
465    ///
466    /// ```ignore
467    /// App::builder()
468    ///     .disable_default_error_pages()
469    ///     .build()?
470    /// ```
471    pub fn disable_default_error_pages(mut self) -> Self {
472        self.default_error_pages = false;
473        self
474    }
475
476    /// Install a CORS policy as the outermost middleware.
477    ///
478    /// The framework doesn't install a `CorsLayer` by default —
479    /// same-origin requests need no policy, and CORS is too
480    /// security-sensitive to enable implicitly. Pass a
481    /// [`crate::cors::CorsConfig`] (start from
482    /// [`CorsConfig::strict`](crate::cors::CorsConfig::strict) for
483    /// production or [`CorsConfig::permissive`](crate::cors::CorsConfig::permissive)
484    /// for dev).
485    ///
486    /// ```ignore
487    /// use umbral::prelude::*;
488    /// use umbral::cors::CorsConfig;
489    ///
490    /// App::builder()
491    ///     .cors(CorsConfig::strict()
492    ///         .allow_origin("https://app.example.com")
493    ///         .allow_credentials(true))
494    ///     .build()
495    ///     .await?
496    /// ```
497    ///
498    /// The layer is applied LAST in the middleware chain so it
499    /// becomes the outermost wrapper — preflight `OPTIONS` is
500    /// answered before any plugin / handler sees the request, and
501    /// the response headers are added on the way back out
502    /// regardless of which downstream layer produced the body.
503    pub fn cors(mut self, config: crate::cors::CorsConfig) -> Self {
504        self.cors = Some(config);
505        self
506    }
507
508    /// Apply a CORS policy scoped to requests whose path starts with `prefix`
509    /// (e.g. `"/api"`), leaving every other route's responses untouched. The
510    /// path-scoped counterpart to [`cors`](Self::cors) — the shape you want for
511    /// "CORS on the REST API, not the HTML pages." Call repeatedly for several
512    /// prefixes. Scoped policies are applied after (outside) the global one.
513    ///
514    /// ```ignore
515    /// use umbral::cors::CorsConfig;
516    ///
517    /// App::builder()
518    ///     .cors_for("/api", CorsConfig::strict()
519    ///         .allow_origins(vec!["https://app.example.com"])
520    ///         .allow_credentials(true))
521    ///     .build()
522    ///     .await?
523    /// ```
524    pub fn cors_for(mut self, prefix: impl Into<String>, config: crate::cors::CorsConfig) -> Self {
525        self.cors_scoped.push((prefix.into(), config));
526        self
527    }
528
529    /// Default every ORM write to run inside its own transaction.
530    ///
531    /// When `enabled = true`, terminals that opt into the contract
532    /// (`Manager::create`, `Manager::bulk_create`,
533    /// `Manager::get_or_create`, `QuerySet::update_values`,
534    /// `QuerySet::delete`) wrap their work in a BEGIN / COMMIT pair
535    /// unless the caller explicitly opts out with `.non_atomic()`.
536    ///
537    /// This is the safe-by-default posture: a framework that claims
538    /// "secure by default" should also be "transaction-safe by
539    /// default." Opting out matters mostly for high-throughput seed
540    /// scripts that already wrap an outer transaction themselves.
541    ///
542    /// Without this flag the framework's behaviour is unchanged —
543    /// writes run with whatever transaction the caller arranges. The
544    /// per-call `.atomic()` / `.non_atomic()` overrides still work.
545    pub fn atomic_transactions(mut self, enabled: bool) -> Self {
546        self.atomic_transactions = Some(enabled);
547        self
548    }
549
550    /// Compress responses with gzip / brotli (a `tower-http`
551    /// `CompressionLayer`). The algorithm is chosen from the request's
552    /// `Accept-Encoding`; already-encoded or non-compressible content types
553    /// are skipped automatically.
554    ///
555    /// Off by default: in most deployments the reverse proxy (nginx, a CDN)
556    /// already compresses, and doing it twice is wasted CPU. Enable this
557    /// when you serve directly (a single binary with no proxy in front).
558    pub fn compression(mut self) -> Self {
559        self.compress = true;
560        self
561    }
562
563    /// Register a framework-level [`Middleware`](crate::middleware::Middleware)
564    /// (feature #68) with `before_request` / `after_response` hooks.
565    ///
566    /// App-level middleware is added to the stack *before* any plugin's
567    /// contribution, so its `before_request` runs first and its
568    /// `after_response` runs last (it's the outermost layer of the onion).
569    /// Call this multiple times to register several, in order.
570    ///
571    /// Use this for the common "look at every request / response" case.
572    /// For a real tower `Layer` (timeouts, body limits) reach for the
573    /// router directly via a plugin's `wrap_router`.
574    pub fn middleware<M: crate::middleware::Middleware>(mut self, mw: M) -> Self {
575        self.middleware.push(std::sync::Arc::new(mw));
576        self
577    }
578
579    /// Finalize the application.
580    ///
581    /// Phases (see spec 01 §Mechanics and invariants and spec 02
582    /// §Dependency ordering):
583    ///
584    /// 1. **Collect.** Gather settings, databases, and router from
585    ///    builder-local state. Settings must be set explicitly via
586    ///    `.settings(...)`; the "default" database pool must be
587    ///    registered via `.database("default", pool)`. The caller
588    ///    opens the pool first (with `umbral::db::connect(...).await`)
589    ///    and hands it to the builder. This matches the canonical
590    ///    pattern in spec 01-app-and-settings.md.
591    /// 2. **Validate plugins.** Reject the reserved `"app"` name,
592    ///    reject duplicate `Plugin::name()`s, verify every entry in a
593    ///    `dependencies()` list points at a registered plugin, and
594    ///    compute a stable topological order. Cycles surface as
595    ///    `BuildError::PluginCycle`.
596    /// 3. **Detect backend.** `backend::detect(&settings.database_url)`
597    ///    picks one of the shipped `DatabaseBackend` impls (M4
598    ///    abstraction). An unknown URL scheme (mysql / oracle / etc.)
599    ///    fails here, before any system check runs.
600    /// 4. **Publish ambient state.** Write settings, pools, and the
601    ///    active backend into their `OnceLock`s. The model registry
602    ///    carries one entry per plugin (the implicit `"app"` plus every
603    ///    registered plugin's `Plugin::models()`).
604    /// 5. **System check.** Run framework-built-in checks plus every
605    ///    plugin's `system_checks()` (concatenated in topological order)
606    ///    against the just-published context. Errors block boot;
607    ///    warnings log and continue.
608    /// 6. **Build router.** Start from the hand-written router (or a
609    ///    fallback handler), then merge every plugin's `routes()` in
610    ///    topological order. axum's `Router::merge` panics on
611    ///    duplicate routes with a clear message.
612    /// 7. **Fire `on_ready`.** Call each plugin's `on_ready(&AppContext)`
613    ///    in topological order. A failure here surfaces as
614    ///    `BuildError::PluginOnReady`.
615    ///
616    /// `build()` is intentionally sync. Earlier iterations auto-opened
617    /// the default pool from `settings.database_url` by spinning up a
618    /// throwaway tokio runtime to drive `db::connect`. That panicked
619    /// when called from inside any caller that was already in a tokio
620    /// runtime ("Cannot start a runtime from within a runtime"), which
621    /// is every realistic case. Requiring an explicit `.database(...)`
622    /// is both spec-correct and avoids the trap.
623    pub fn build(mut self) -> Result<App, BuildError> {
624        // Phase 1 — collect
625        let settings = self.settings.take().ok_or(BuildError::SettingsMissing)?;
626
627        if !self.databases.contains_key("default") {
628            return Err(BuildError::DefaultPoolMissing);
629        }
630
631        // Phase 1.5 — validate plugins and compute a stable topological
632        // order. Reserved-name and duplicate-name checks reject the
633        // build before any ambient state gets published; the toposort
634        // surfaces both missing deps and cycles as `BuildError`. The
635        // sorted vec is reused in phases 3 / 4 / 5 / 6 so every plugin
636        // walk reads from one canonical order, then handed to `App` so
637        // post-build callers (notably `umbral::cli::dispatch`) can walk
638        // the same list.
639        let sorted_plugins = sort_plugins(std::mem::take(&mut self.plugins))?;
640
641        // Phase 2 — detect backend from the configured URL.
642        let backend =
643            crate::backend::detect(&settings.database_url).map_err(BuildError::BackendDetect)?;
644
645        // Phase 2.1 — cross-check the registered default pool's
646        // backend against the URL-derived one. A mismatch (e.g. the
647        // URL says `sqlite://` but the caller passed in a `PgPool`)
648        // surfaces here with a clear name pair rather than as a
649        // confusing query-time error.
650        let default_pool = self
651            .databases
652            .get("default")
653            .expect("contains_key check above");
654        if default_pool.backend_name() != backend.name() {
655            return Err(BuildError::DatabaseBackendMismatch {
656                url_backend: backend.name(),
657                pool_backend: default_pool.backend_name(),
658            });
659        }
660
661        // Phase 2.5 — validate every plugin's `database()` alias
662        // against the registered pool set BEFORE phase 3 moves
663        // `self.databases` into the ambient registry. Lets a typo
664        // surface at boot with a clear diagnostic instead of as a
665        // runtime "no pool registered" panic from `db::pool_for`.
666        // Also collect the per-model alias map for `init_model_aliases`
667        // below. Two layers: plugin-level (`Plugin::database()`) and
668        // per-model (`#[umbral(database = "alias")]` → `Model::DATABASE`,
669        // surfaced via `ModelMeta::database`). Per-model wins when both
670        // are set — useful for a plugin that owns one model on the
671        // primary DB and another on an analytics/archive DB. Same alias
672        // validation: a typo surfaces at boot, not at runtime.
673        let mut model_aliases: HashMap<String, String> = HashMap::new();
674        for plugin in &sorted_plugins {
675            // Plugin-level default for every model this plugin contributes.
676            if let Some(alias) = plugin.database() {
677                if !self.databases.contains_key(alias) {
678                    return Err(BuildError::PluginDatabaseAlias {
679                        plugin: plugin.name(),
680                        alias,
681                    });
682                }
683                for model in plugin.models() {
684                    model_aliases.insert(model.name, alias.to_string());
685                }
686            }
687            // Per-model overrides — walked AFTER the plugin pass so they
688            // can supersede the plugin's choice.
689            for model in plugin.models() {
690                if let Some(alias) = &model.database {
691                    if !self.databases.contains_key(alias) {
692                        return Err(BuildError::PluginDatabaseAlias {
693                            plugin: plugin.name(),
694                            alias: Box::leak(alias.clone().into_boxed_str()),
695                        });
696                    }
697                    model_aliases.insert(model.name.clone(), alias.clone());
698                }
699            }
700        }
701        // Same per-model walk for the implicit `"app"` plugin's
702        // user-registered models, which don't have a `Plugin::database()`
703        // wrapper to inherit from.
704        for model in &self.models {
705            if let Some(alias) = &model.database {
706                if !self.databases.contains_key(alias) {
707                    return Err(BuildError::PluginDatabaseAlias {
708                        plugin: crate::migrate::APP_PLUGIN_NAME,
709                        alias: Box::leak(alias.clone().into_boxed_str()),
710                    });
711                }
712                model_aliases.insert(model.name.clone(), alias.clone());
713            }
714        }
715
716        // Phase 2.5b — cross-database foreign-key guard (gaps2 #22).
717        //
718        // A foreign key whose target model lives on a DIFFERENT database
719        // can't be a real DB constraint — `REFERENCES` can't span pools.
720        // We resolve each model's effective alias (plugin default, then
721        // per-model override, else "default") into a table→alias map,
722        // then check every FK column: if the column's target table
723        // routes to a different alias than the model AND the field has
724        // not opted out via `#[umbral(db_constraint = false)]`, the build
725        // fails loudly here rather than emitting an invalid `FOREIGN KEY`
726        // line at migration time.
727        //
728        // Build the table→alias map with the same precedence as
729        // `model_aliases` above: plugin default first, per-model override
730        // wins, the implicit "app" models last. Any table not mentioned
731        // routes to "default".
732        let mut table_alias: HashMap<String, String> = HashMap::new();
733        for plugin in &sorted_plugins {
734            let plugin_default = plugin.database();
735            for model in plugin.models() {
736                let alias = model
737                    .database
738                    .clone()
739                    .or_else(|| plugin_default.map(|s| s.to_string()))
740                    .unwrap_or_else(|| "default".to_string());
741                table_alias.insert(model.table.clone(), alias);
742            }
743        }
744        for model in &self.models {
745            let alias = model
746                .database
747                .clone()
748                .unwrap_or_else(|| "default".to_string());
749            table_alias.insert(model.table.clone(), alias);
750        }
751        // Helper to resolve a table's alias, defaulting to "default".
752        let alias_of = |table: &str| -> String {
753            table_alias
754                .get(table)
755                .cloned()
756                .unwrap_or_else(|| "default".to_string())
757        };
758        // Walk every model's FK fields and check each FK relation. The
759        // default (no custom router) path keeps today's build-time local
760        // alias equality (`alias_of(a) == alias_of(b)`): the trait's
761        // DEFAULT `allow_relation` reads the GLOBAL `model_alias`, which is
762        // still unpublished at this Phase 2.5b point, so routing the
763        // default case through the trait would compare "default" == "default"
764        // for everything and silently disable the #22 guard. A CUSTOM router
765        // is asked directly via `allow_relation`.
766        //
767        // Materialize the models into a Vec so we can both build a
768        // table→meta lookup AND iterate them.
769        let all_models: Vec<ModelMeta> = sorted_plugins
770            .iter()
771            .flat_map(|p| p.models())
772            .chain(self.models.iter().cloned())
773            .collect();
774        let meta_by_table: HashMap<&str, &ModelMeta> =
775            all_models.iter().map(|m| (m.table.as_str(), m)).collect();
776        // Clone the candidate router — install still happens at Phase 3, so
777        // we must NOT take/consume `self.db_router` here.
778        let candidate_router = self.db_router.clone();
779        for model in &all_models {
780            for field in &model.fields {
781                let Some(target_table) = field.fk_target.as_deref() else {
782                    continue;
783                };
784                if !field.db_constraint {
785                    continue;
786                }
787                let allowed = match &candidate_router {
788                    Some(r) => match meta_by_table.get(target_table) {
789                        Some(target_meta) => r.allow_relation(model, target_meta),
790                        // Target isn't a registered model (shouldn't happen
791                        // for a real FK); don't false-reject — fall back to
792                        // the local alias check.
793                        None => alias_of(&model.table) == alias_of(target_table),
794                    },
795                    // No custom router: today's build-time local alias
796                    // equality (#22).
797                    None => alias_of(&model.table) == alias_of(target_table),
798                };
799                if !allowed {
800                    let model_db = alias_of(&model.table);
801                    let target_db = alias_of(target_table);
802                    return Err(BuildError::CrossDatabaseForeignKey {
803                        model: Box::leak(model.name.clone().into_boxed_str()),
804                        field: Box::leak(field.name.clone().into_boxed_str()),
805                        model_db: Box::leak(model_db.into_boxed_str()),
806                        target_db: Box::leak(target_db.into_boxed_str()),
807                    });
808                }
809            }
810        }
811
812        // Phase 2.6 — publish the default-error-pages flag before the
813        // templates engine starts so `errors::default_pages_enabled()` is
814        // correct the moment any 404/500 helper is called.
815        crate::errors::init_default_pages(self.default_error_pages);
816
817        // Phase 3 — publish ambient state. The model registry now carries
818        // one entry per registered plugin (the implicit `"app"` plugin
819        // for `.model::<T>()` registrations, plus every `.plugin(...)`
820        // contribution). Plugins that contribute zero models still get a
821        // map entry; the flattening in `migrate::init_plugins` collapses
822        // them to nothing in the registry but the per-plugin model walk
823        // stays deterministic.
824        crate::settings::init(&settings);
825        db::init(self.databases);
826        if let Some(router) = self.db_router {
827            crate::db::router::install_router(router);
828        }
829        crate::backend::init(backend);
830        if let Some(enabled) = self.atomic_transactions {
831            db::init_atomic_default(enabled);
832        }
833
834        let mut per_plugin: HashMap<String, Vec<ModelMeta>> = HashMap::new();
835        per_plugin.insert(
836            crate::migrate::APP_PLUGIN_NAME.to_string(),
837            std::mem::take(&mut self.models),
838        );
839        for plugin in &sorted_plugins {
840            per_plugin.insert(plugin.name().to_string(), plugin.models());
841        }
842        crate::migrate::init_plugins(per_plugin);
843
844        // Publish the topological plugin order so the migration engine
845        // walks plugins in dependency order. The implicit "app" plugin
846        // (owner of `.model::<T>()` registrations) lands LAST: app models
847        // typically hold ForeignKeys INTO plugin-owned tables (e.g.
848        // `Post.author -> auth_user`), so those tables must be created
849        // first. Postgres enforces FK targets at CREATE TABLE, so ordering
850        // "app" first made app-model migrations fail there with
851        // `relation "auth_user" does not exist` (SQLite silently allowed
852        // the dangling FK, hiding the bug in local dev).
853        let mut order: Vec<String> = Vec::with_capacity(sorted_plugins.len() + 1);
854        for plugin in &sorted_plugins {
855            order.push(plugin.name().to_string());
856        }
857        order.push(crate::migrate::APP_PLUGIN_NAME.to_string());
858        crate::migrate::init_plugin_order(order);
859
860        // Collect every plugin's advertised API endpoints into a global
861        // so a discovery surface (umbral-rest's API root) can list them
862        // without depending on the contributing plugins' crates. In
863        // registration order; plugins that advertise nothing contribute
864        // nothing.
865        let mut api_endpoints = Vec::new();
866        for plugin in &sorted_plugins {
867            api_endpoints.extend(plugin.api_endpoints());
868        }
869        crate::migrate::init_api_endpoints(api_endpoints);
870
871        // Publish the per-plugin model alias map collected in phase
872        // 2.5. Done after `migrate::init_plugins` so the migration
873        // registry is alive when QuerySet's resolve_pool starts
874        // looking up by `Model::NAME`.
875        crate::migrate::init_model_aliases(model_aliases);
876
877        // Snapshot the declared route paths into the registry so the
878        // dev-mode 404 page can surface them. The implicit `"app"`
879        // plugin holds whatever `.route_paths([...])` declared on the
880        // builder; each registered plugin contributes its own list.
881        // Empty entries are kept so the listing distinguishes "plugin
882        // present, no routes" from "plugin absent".
883        let mut route_registry = crate::routes::RouteRegistry::default();
884        route_registry.by_plugin.insert(
885            crate::migrate::APP_PLUGIN_NAME.to_string(),
886            std::mem::take(&mut self.route_paths),
887        );
888        for plugin in &sorted_plugins {
889            route_registry
890                .by_plugin
891                .insert(plugin.name().to_string(), plugin.route_paths());
892        }
893        crate::routes::init(route_registry);
894
895        // BUG-20: publish every plugin's OpenAPI path contribution
896        // so umbral-openapi can merge them into the emitted spec.
897        // Flat (path, value) list — multiple plugins contributing
898        // the same path produce duplicate entries; umbral-openapi's
899        // merge step picks the first.
900        let mut openapi_entries: Vec<(String, serde_json::Value)> = Vec::new();
901        for plugin in &sorted_plugins {
902            openapi_entries.extend(plugin.openapi_paths());
903        }
904        crate::routes::init_openapi(openapi_entries);
905
906        // Templates engine — published before phase 4 so a future
907        // plugin system_check that wants to inspect the loaded
908        // templates can.
909        //
910        // Search order (first-match-wins across all template directories):
911        //   1. App-level dir: set via `.templates_dir(...)` or `./templates`.
912        //   2. Plugin dirs: each plugin's `templates_dirs()` contributions,
913        //      in topological dependency order.
914        //
915        // The engine warns (via tracing) when two directories ship a
916        // template with the same name — the first-registered copy wins.
917        let app_templates_dir = self
918            .templates_dir
919            .take()
920            .unwrap_or_else(|| std::path::PathBuf::from("templates"));
921        let mut all_template_dirs: Vec<std::path::PathBuf> = vec![app_templates_dir];
922        for plugin in &sorted_plugins {
923            all_template_dirs.extend(plugin.templates_dirs());
924        }
925        // features.md #67 — collect every plugin's custom tags/filters in
926        // topological order so a dependency's registrar runs before its
927        // dependent's (and a later plugin can override an earlier one).
928        let mut template_registrars: Vec<crate::templates::TemplateRegistrar> = Vec::new();
929        for plugin in &sorted_plugins {
930            template_registrars.extend(plugin.template_registrars());
931        }
932        // `init_with` returns the list of collision names (templates present
933        // in more than one directory). We log each one via tracing here so
934        // the `App::build()` phase is the single point that handles warnings;
935        // `templates::init` itself also emits tracing::warn! for each, but
936        // returning the list lets callers (tests) assert without a subscriber.
937        let _collisions = crate::templates::init_with(&all_template_dirs, template_registrars)
938            .map_err(BuildError::TemplatesInit)?;
939
940        // Phase 4 — system check. Build the context against ambient
941        // state, run the framework checks plus every plugin's
942        // contribution in topological order, partition into errors vs
943        // warnings, log the warnings, fail the build on any errors.
944        // Whether any registered plugin declares a Storage backend. Read
945        // by the `field.storage_backend` check; computed from the
946        // capability flag (not the ambient `storage_opt()`) because
947        // backends register in `on_ready`, which runs *after* this phase.
948        let provides_storage = sorted_plugins.iter().any(|p| p.provides_storage());
949        let plugin_names: Vec<&str> = sorted_plugins.iter().map(|p| p.name()).collect();
950        let ctx = crate::check::CheckContext {
951            backend,
952            settings: crate::settings::get(),
953            provides_storage,
954            registered_plugin_names: &plugin_names,
955        };
956        let mut checks = crate::check::framework_checks();
957        for plugin in &sorted_plugins {
958            checks.extend(plugin.system_checks());
959        }
960        let findings = crate::check::run_all(&ctx, &checks);
961        let mut errors = Vec::new();
962        for finding in findings {
963            match finding.severity {
964                crate::check::Severity::Error => errors.push(finding),
965                crate::check::Severity::Warning => {
966                    tracing::warn!(
967                        check = finding.check_id,
968                        "umbral system check warning: {}",
969                        finding.message
970                    );
971                }
972            }
973        }
974        if !errors.is_empty() {
975            return Err(BuildError::SystemCheckFailed { findings: errors });
976        }
977
978        // Phase 5 — build the merged router. Start from the hand-written
979        // router (or a fallback handler if none was registered), then
980        // merge every plugin's routes in topological order. axum's
981        // `Router::merge` composes path tables; conflicts panic with a
982        // clear message.
983        let mut router = self.router.unwrap_or_else(|| {
984            Router::new().fallback(|| async { "umbral is running, but no routes are registered." })
985        });
986        for plugin in &sorted_plugins {
987            router = router.merge(plugin.routes());
988            // Phase 5.4 — mount the plugin's `include_bytes!`-embedded
989            // assets. Each StaticFile becomes a GET route serving the
990            // body with the supplied content-type + cache-control.
991            for file in plugin.static_files() {
992                router = router.route(
993                    file.url_path,
994                    axum::routing::get(move || async move {
995                        use axum::response::IntoResponse;
996                        let cc = file.cache_control.unwrap_or("public, max-age=86400");
997                        axum::http::Response::builder()
998                            .status(axum::http::StatusCode::OK)
999                            .header(axum::http::header::CONTENT_TYPE, file.content_type)
1000                            .header(axum::http::header::CACHE_CONTROL, cc)
1001                            .body(axum::body::Body::from(file.body))
1002                            .unwrap_or_else(|_| {
1003                                axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
1004                            })
1005                    }),
1006                );
1007            }
1008        }
1009
1010        // Phase 5.45 — mount the unified static pipeline handler. Walk
1011        // every plugin's `static_dirs()` into a namespace -> source_dir
1012        // registry (a duplicate namespace fails the build loudly), then
1013        // nest ONE handler at the configured `static_url` base. It
1014        // resolves `/static/<ns>/<rest>` live-from-source in dev and
1015        // from `static_root` in prod (see `crate::static_files`).
1016        //
1017        // This coexists with the `StaticFile` embedded routes mounted in
1018        // Phase 5.4 above — embedded assets stay the zero-config default;
1019        // the filesystem handler is additive.
1020        //
1021        // A CDN-style `static_url` (an absolute http(s):// origin) can't
1022        // be nested as a local route prefix; in that mode assets are
1023        // served off the CDN and the local handler is intentionally not
1024        // mounted — the `static()` template helper still emits the
1025        // absolute URLs.
1026        let settings = crate::settings::get();
1027        let static_base = settings.static_url.trim_end_matches('/');
1028        let is_cdn_url = settings.static_url.starts_with("http://")
1029            || settings.static_url.starts_with("https://")
1030            || settings.static_url.starts_with("//");
1031
1032        // App/site-level static dirs served at the bare `static_url` root.
1033        // A `StoragePlugin`'s static side mounted AT `static_url` contributes its
1034        // directory here (and skips nesting its own catch-all), so the
1035        // framework owns `static_url` as ONE mount — a second
1036        // `/static/{*rest}` nest is exactly the conflict this avoids.
1037        let root_dirs = crate::static_files::StaticContribution::collect_root_dirs(&sorted_plugins);
1038
1039        // Publish the static contributions ambiently for `collectstatic`
1040        // (the `StoragePlugin` CLI command). Published UNCONDITIONALLY —
1041        // before the serving-mode gate below — because `collectstatic`
1042        // copies assets to disk regardless of serving mode (a CDN-mode
1043        // app still needs the disk tree built for upload). Mirrors the
1044        // `settings` ambient OnceLock: read-only config set once at build.
1045        crate::static_files::publish_static(crate::static_files::PublishedStatic {
1046            contributions: crate::static_files::StaticContribution::collect(&sorted_plugins),
1047            root_dirs: root_dirs.clone(),
1048        });
1049
1050        // Load the hashed-asset manifest (`<static_root>/staticfiles.json`)
1051        // if `collectstatic --hashed` has produced one. With a manifest
1052        // present, `resolve_static_url` / the `static()` template global
1053        // emit content-hashed URLs so prod assets carry far-future cache
1054        // headers. Absent (no `--hashed` run), this is a no-op and URLs
1055        // stay plain. Loaded unconditionally — the URL resolution applies
1056        // whether or not this app serves the bytes itself.
1057        crate::static_files::load_manifest(&settings.static_root);
1058
1059        if !is_cdn_url && !static_base.is_empty() {
1060            let registry = crate::static_files::StaticRegistry::from_plugins(&sorted_plugins)
1061                .map_err(|c| BuildError::DuplicateStaticNamespace {
1062                    namespace: c.namespace,
1063                    first_plugin: c.first_plugin,
1064                    second_plugin: c.second_plugin,
1065                })?;
1066            // Nothing to serve and no app static dirs — don't claim the
1067            // `static_url` path at all, so a consumer that wants to mount
1068            // their own router there can.
1069            if !registry.is_empty() || !root_dirs.is_empty() {
1070                let state = crate::static_files::StaticHandlerState {
1071                    registry,
1072                    static_root: std::path::PathBuf::from(&settings.static_root),
1073                    root_dirs,
1074                    dev: matches!(settings.environment, crate::settings::Environment::Dev),
1075                };
1076                let static_router = Router::new()
1077                    .fallback(crate::static_files::static_handler)
1078                    .with_state(state);
1079                router = router.nest_service(static_base, static_router);
1080            }
1081        }
1082
1083        // Phase 5.5 — apply each plugin's middleware in topological
1084        // order. Later plugins wrap earlier ones, so a security
1085        // plugin declared after the auth plugin sees the auth-
1086        // augmented router and can add its own layer on top. This
1087        // is the M7 deferral being lifted now that umbral-security
1088        // needs it.
1089        for plugin in &sorted_plugins {
1090            router = plugin.wrap_router(router);
1091        }
1092
1093        // Phase 5.6 — install the 404 fallback. Four cases:
1094        //
1095        // 1. slash_redirect = Off, not_found_template = None, default pages off:
1096        //    no-op. axum's built-in empty 404 is what users see.
1097        // 2. slash_redirect = Off, not_found_template = None, default pages ON:
1098        //    install the not-found fallback; render_not_found will use the
1099        //    embedded default_404 template.
1100        // 3. slash_redirect = Off, not_found_template = Some(name):
1101        //    install the not-found fallback directly. Renders the
1102        //    template on every miss.
1103        // 4. slash_redirect != Off:
1104        //    install the slash-redirect fallback. It handles its own
1105        //    404 path internally — when no alternate matches, it
1106        //    renders the configured not-found template (or the default
1107        //    if enabled, or plain text if both are absent).
1108        //
1109        // The slash-redirect fallback ALWAYS captures a router
1110        // snapshot taken BEFORE the fallback is installed, so the
1111        // alternate-path probe can't recursively re-hit the fallback.
1112        let need_not_found_fallback = self.not_found_template.is_some() || self.default_error_pages;
1113        match (self.slash_redirect, need_not_found_fallback) {
1114            (crate::slash::SlashRedirect::Off, false) => {
1115                // axum's default 404 — nothing to do.
1116            }
1117            (crate::slash::SlashRedirect::Off, true) => {
1118                let fallback = crate::errors::not_found_fallback(self.not_found_template.clone());
1119                router = router.fallback(fallback);
1120            }
1121            (policy, _) => {
1122                let snapshot = router.clone();
1123                let fallback = crate::slash::slash_redirect_fallback(
1124                    snapshot,
1125                    policy,
1126                    self.not_found_template.clone(),
1127                );
1128                router = router.fallback(fallback);
1129            }
1130        }
1131
1132        // Phase 5.65 — framework middleware stack (feature #68). App-level
1133        // middleware first, then every plugin's contribution in topological
1134        // order, collected into one stack and installed as a single layer.
1135        // Placed AFTER the 404 fallback so middleware sees misses too, and
1136        // BEFORE the panic / compression / CORS / host layers so those stay
1137        // the outermost wrappers (security and content-encoding run before
1138        // user middleware ever touches the request).
1139        let mut middleware_stack = crate::middleware::MiddlewareStack::new();
1140        middleware_stack.extend(std::mem::take(&mut self.middleware));
1141        for plugin in &sorted_plugins {
1142            middleware_stack.extend(plugin.middleware());
1143        }
1144        router = middleware_stack.apply(router);
1145
1146        // Phase 5.66 — request-scoped routing context (DatabaseRouter
1147        // foundation). When a resolver is registered, wrap the whole
1148        // downstream future in `route_context::scope`. Installed OUTSIDE the
1149        // middleware stack above so the task-local is established before any
1150        // middleware or handler runs — every `.await` in the request,
1151        // including ORM calls that read `route_context::current()`, then sees
1152        // the resolved context. A `from_fn` layer is the only mechanism that
1153        // can wrap `next.run(req)` in a scope; the `Middleware` contract's
1154        // `before_request(req) -> req` cannot.
1155        if let Some(resolver) = self.route_context_resolver.take() {
1156            router = router.layer(axum::middleware::from_fn_with_state(
1157                resolver,
1158                route_context_scope_layer,
1159            ));
1160        }
1161
1162        // Phase 5.7 — wrap with the panic-catch layer. Comes AFTER the
1163        // fallback wiring so a panicking fallback handler is also caught
1164        // (the panic-catch layer wraps the entire router).
1165        //
1166        // Always installed when: a user-supplied server_error_template is
1167        // set, OR default pages are enabled (the embedded default_500 fires
1168        // in that case), OR an on_server_error hook is registered.
1169        let need_panic_layer = self.server_error_template.is_some()
1170            || self.default_error_pages
1171            || self.server_error_hook.is_some();
1172        if need_panic_layer {
1173            let handler = crate::errors::server_error_panic_handler(
1174                self.server_error_template.clone(),
1175                self.server_error_hook.clone(),
1176            );
1177            router = router.layer(tower_http::catch_panic::CatchPanicLayer::custom(handler));
1178
1179            // Phase 5.8 — wrap with the response-rendering middleware so
1180            // any 500 produced by a handler (not just a panic) gets
1181            // re-rendered through the configured 500 template. The
1182            // middleware checks Content-Type: HTML responses (from the
1183            // panic handler above, or from a handler that rendered its
1184            // own template) pass through; plain-text 500s get re-rendered.
1185            // Also fires `on_server_error` for handler-Err paths.
1186            let render_state = crate::errors::Render500State {
1187                template: self.server_error_template.clone(),
1188                hook: self.server_error_hook.clone(),
1189            };
1190            router = router.layer(axum::middleware::from_fn_with_state(
1191                render_state,
1192                crate::errors::render_500_middleware,
1193            ));
1194        }
1195
1196        // General custom error pages: style any registered status code
1197        // (429/403/410/…) the way the 500 path does, for handler-Err
1198        // responses — rendering each through its template while preserving the
1199        // status. Already-HTML and unregistered statuses pass through; this is
1200        // independent of the 500 layer above (different status codes).
1201        if !self.error_templates.is_empty() {
1202            let state = crate::errors::RenderErrorState {
1203                templates: std::sync::Arc::new(std::mem::take(&mut self.error_templates)),
1204            };
1205            router = router.layer(axum::middleware::from_fn_with_state(
1206                state,
1207                crate::errors::render_error_middleware,
1208            ));
1209        }
1210
1211        // Optional response compression (gzip / brotli), opt-in via
1212        // `AppBuilder::compression`. tower-http chooses the algorithm from
1213        // `Accept-Encoding` and skips already-encoded / non-compressible
1214        // bodies. Applied here so it wraps handler responses; CORS + host
1215        // checks layer outside it.
1216        if self.compress {
1217            router = router.layer(tower_http::compression::CompressionLayer::new());
1218        }
1219
1220        // Phase 5.9 — CORS, applied last so it's the outermost
1221        // wrapper. Preflight `OPTIONS` is answered before any
1222        // plugin/handler sees the request; response headers are
1223        // added on the way back out regardless of which downstream
1224        // layer produced the body.
1225        if let Some(cors) = self.cors.take() {
1226            router = router.layer(cors.into_layer());
1227        }
1228        // Path-scoped CORS (e.g. `/api`) — layered after the global one so each
1229        // only touches responses for requests under its prefix.
1230        for (prefix, config) in std::mem::take(&mut self.cors_scoped) {
1231            router = router.layer(crate::cors::ScopedCorsLayer::new(
1232                prefix,
1233                config.into_layer(),
1234            ));
1235        }
1236
1237        // Phase 5.95 — Host-header validation (allowed-hosts allowlist). Applied
1238        // outermost so a forged `Host` is rejected with a 400 before any
1239        // handler, plugin, or CORS logic runs. Enforced only in
1240        // `Environment::Prod`; dev passes through. Allowlist is
1241        // `settings.allowed_hosts` (`"*"` disables; `.example.com` = subdomain).
1242        let host_policy = crate::hosts::HostPolicy::new(
1243            &settings.allowed_hosts,
1244            matches!(settings.environment, crate::settings::Environment::Prod),
1245        );
1246        router = router.layer(axum::middleware::from_fn_with_state(
1247            host_policy,
1248            crate::hosts::host_guard,
1249        ));
1250
1251        // Phase 5.99 — request tracing span. Applied outermost so every request
1252        // (including host-guard rejections) runs inside a span. The span
1253        // carries `http.method`, `http.route`/`uri`, and the response
1254        // `http.status_code`; this is what an OpenTelemetry layer (installed by
1255        // an app via `umbral_logs::observability::init`) exports as one span per
1256        // request. Without an OTel layer attached it's a cheap `tracing` span
1257        // that the fmt subscriber can surface under `RUST_LOG=tower_http=debug`.
1258        // W3C `traceparent` propagation (extracting an upstream trace context
1259        // from the inbound header) is a noted follow-up; this layer creates the
1260        // local request span.
1261        router = router.layer(
1262            tower_http::trace::TraceLayer::new_for_http().make_span_with(
1263                |request: &axum::http::Request<axum::body::Body>| {
1264                    tracing::info_span!(
1265                        "http.request",
1266                        http.method = %request.method(),
1267                        http.route = %request.uri().path(),
1268                        http.status_code = tracing::field::Empty,
1269                    )
1270                },
1271            ),
1272        );
1273
1274        // Phase 6 — fire each plugin's `on_ready` in topological order.
1275        // Runs after the system check passes and after the router is
1276        // built, so a plugin can rely on ambient state being live and on
1277        // any earlier dependency's `on_ready` having already run.
1278        let ctx = crate::plugin::AppContext {
1279            pool: crate::db::pool_dispatched().clone(),
1280            settings: crate::settings::get().clone(),
1281        };
1282        for plugin in &sorted_plugins {
1283            plugin
1284                .on_ready(&ctx)
1285                .map_err(|source| BuildError::PluginOnReady {
1286                    plugin: plugin.name(),
1287                    source,
1288                })?;
1289        }
1290
1291        Ok(App {
1292            router,
1293            plugins: sorted_plugins,
1294        })
1295    }
1296}
1297
1298/// The axum middleware fn installed by [`AppBuilder::route_context`]: run the
1299/// resolver against the incoming request to build a [`crate::db::RouteContext`],
1300/// then drive the ENTIRE downstream future inside
1301/// [`crate::db::route_context::scope`]. Scoping `next.run(req)` (rather than
1302/// just a prefix of it) is what keeps the task-local alive across every
1303/// `.await` the handler performs, so ambient ORM calls route per the resolved
1304/// context.
1305async fn route_context_scope_layer(
1306    axum::extract::State(resolver): axum::extract::State<RouteContextResolver>,
1307    req: crate::web::Request,
1308    next: axum::middleware::Next,
1309) -> crate::web::Response {
1310    let ctx = resolver(&req);
1311    crate::db::route_context::scope(ctx, next.run(req)).await
1312}
1313
1314/// Validate the registered plugins and return them in a stable
1315/// topological order keyed by `Plugin::dependencies()`. Standard Kahn's
1316/// algorithm with a name-sorted ready queue so ties resolve
1317/// deterministically.
1318///
1319/// Rejects:
1320///
1321/// - A plugin claiming the reserved `"app"` name.
1322/// - Two plugins reporting the same `name()`.
1323/// - A `dependencies()` entry that doesn't name a registered plugin.
1324/// - A dependency cycle (the remaining-unsorted set surfaces as
1325///   `BuildError::PluginCycle`).
1326fn sort_plugins(plugins: Vec<Box<dyn Plugin>>) -> Result<Vec<Box<dyn Plugin>>, BuildError> {
1327    use std::collections::{BTreeMap, BTreeSet};
1328
1329    // Reserved + duplicate-name checks. The implicit `"app"` plugin is
1330    // not counted toward duplicates; only the user's plugin list is.
1331    let mut seen: BTreeSet<&'static str> = BTreeSet::new();
1332    for plugin in &plugins {
1333        let name = plugin.name();
1334        if name == crate::migrate::APP_PLUGIN_NAME {
1335            return Err(BuildError::ReservedPluginName);
1336        }
1337        if !seen.insert(name) {
1338            return Err(BuildError::DuplicatePluginName { name });
1339        }
1340    }
1341
1342    // Index plugins by name for the dependency lookups + the
1343    // sort-by-name traversal below. We pull the boxes out of the
1344    // input vec by index later, so the index table stays alongside.
1345    let by_name: BTreeMap<&'static str, usize> = plugins
1346        .iter()
1347        .enumerate()
1348        .map(|(i, p)| (p.name(), i))
1349        .collect();
1350
1351    // Dependency-exists check. Done before the toposort so a missing
1352    // dep surfaces with the asking plugin's name attached, not as a
1353    // cycle false-positive.
1354    for plugin in &plugins {
1355        for dep in plugin.dependencies() {
1356            if !by_name.contains_key(dep) {
1357                return Err(BuildError::DependencyNotFound {
1358                    plugin: plugin.name(),
1359                    missing: dep,
1360                });
1361            }
1362        }
1363    }
1364
1365    // Kahn's algorithm against the index table. `remaining_deps[name]`
1366    // is the set of names this plugin still waits on; once it empties,
1367    // the plugin joins the ready queue. The queue is a sorted set so
1368    // ties resolve by name.
1369    let mut remaining_deps: BTreeMap<&'static str, BTreeSet<&'static str>> = plugins
1370        .iter()
1371        .map(|p| (p.name(), p.dependencies().iter().copied().collect()))
1372        .collect();
1373
1374    let mut ready: BTreeSet<&'static str> = remaining_deps
1375        .iter()
1376        .filter_map(|(name, deps)| if deps.is_empty() { Some(*name) } else { None })
1377        .collect();
1378
1379    let mut order: Vec<&'static str> = Vec::with_capacity(plugins.len());
1380    while let Some(name) = ready.iter().next().copied() {
1381        ready.remove(&name);
1382        remaining_deps.remove(&name);
1383        order.push(name);
1384        for (other_name, deps) in remaining_deps.iter_mut() {
1385            if deps.remove(&name) && deps.is_empty() {
1386                ready.insert(*other_name);
1387            }
1388        }
1389    }
1390
1391    if !remaining_deps.is_empty() {
1392        let names: Vec<&'static str> = remaining_deps.keys().copied().collect();
1393        return Err(BuildError::PluginCycle { names });
1394    }
1395
1396    // Reorder the owned boxes into topological order. We pull each
1397    // plugin out of an `Option` slot so the move is statically
1398    // tracked; every slot is taken exactly once because the toposort
1399    // produced one entry per plugin.
1400    let mut slots: Vec<Option<Box<dyn Plugin>>> = plugins.into_iter().map(Some).collect();
1401    let mut sorted: Vec<Box<dyn Plugin>> = Vec::with_capacity(order.len());
1402    for name in order {
1403        let idx = by_name[&name];
1404        sorted.push(
1405            slots[idx]
1406                .take()
1407                .expect("toposort produced one entry per plugin"),
1408        );
1409    }
1410    Ok(sorted)
1411}
1412
1413/// Errors that can occur during `AppBuilder::build()`.
1414#[derive(Debug)]
1415pub enum BuildError {
1416    /// `.settings(Settings)` wasn't called on the builder.
1417    SettingsMissing,
1418    /// `.database("default", pool)` wasn't called on the builder.
1419    DefaultPoolMissing,
1420    /// The URL scheme in `settings.database_url` doesn't match any
1421    /// shipped backend.
1422    BackendDetect(crate::backend::BackendDetectError),
1423    /// One or more system checks failed with `Severity::Error`. The
1424    /// full list of findings is in the variant.
1425    SystemCheckFailed {
1426        findings: Vec<crate::check::SystemCheckFinding>,
1427    },
1428    /// A plugin's `dependencies()` lists a plugin that was never
1429    /// registered with `.plugin(...)`. Carries the unmet name plus
1430    /// the plugin that asked for it.
1431    DependencyNotFound {
1432        plugin: &'static str,
1433        missing: &'static str,
1434    },
1435    /// The dependency graph has a cycle. Carries the plugin names that
1436    /// form it (in any cyclic order; the diagnostic is "these N plugins
1437    /// reference each other").
1438    PluginCycle { names: Vec<&'static str> },
1439    /// Two registered plugins share a `name()`. Plugin names are keys
1440    /// in the migration tracking table and the dependency graph; a
1441    /// collision would break both.
1442    DuplicatePluginName { name: &'static str },
1443    /// A plugin claimed the reserved `"app"` name (used by the
1444    /// implicit plugin that owns `.model::<T>()` registrations).
1445    ReservedPluginName,
1446    /// A plugin's `on_ready` returned an error. Carries the plugin's
1447    /// name plus the underlying error.
1448    PluginOnReady {
1449        plugin: &'static str,
1450        source: Box<dyn std::error::Error + Send + Sync>,
1451    },
1452    /// The templates engine failed to initialise. Carries the
1453    /// underlying `TemplateError` (an IO error reading a template
1454    /// file, or a syntax error in one of the loaded templates).
1455    TemplatesInit(crate::templates::TemplateError),
1456    /// A plugin's `database()` returned an alias that isn't in the
1457    /// registered pool set. Surfaces a typo at boot with a clear
1458    /// "register the pool first" diagnostic instead of letting
1459    /// `db::pool_for` panic at first query.
1460    PluginDatabaseAlias {
1461        plugin: &'static str,
1462        alias: &'static str,
1463    },
1464    /// The URL-derived backend (from `settings.database_url`) doesn't
1465    /// match the runtime type of the default pool passed to
1466    /// `.database("default", ...)`. Catches the case where the URL
1467    /// says `postgres://` but a `SqlitePool` was registered, or vice
1468    /// versa.
1469    DatabaseBackendMismatch {
1470        url_backend: &'static str,
1471        pool_backend: &'static str,
1472    },
1473    /// A foreign key targets a model on a different database than the
1474    /// model that declares it, and the field has NOT opted out of the
1475    /// physical constraint. A `REFERENCES` clause can't span databases,
1476    /// so this would emit invalid DDL. Fix by either routing both
1477    /// models to the same database, or marking the FK
1478    /// `#[umbral(db_constraint = false)]` to keep it a logical-only
1479    /// relation. Closes gaps2 #22.
1480    CrossDatabaseForeignKey {
1481        model: &'static str,
1482        field: &'static str,
1483        model_db: &'static str,
1484        target_db: &'static str,
1485    },
1486    /// Two plugins declared the same static namespace via
1487    /// `Plugin::static_dirs()`. Namespaces are the per-plugin URL/disk
1488    /// segment under `static_url` / `static_root`; a collision would
1489    /// silently shadow one plugin's assets with another's, so the build
1490    /// fails loudly and names both plugins.
1491    DuplicateStaticNamespace {
1492        namespace: &'static str,
1493        first_plugin: &'static str,
1494        second_plugin: &'static str,
1495    },
1496}
1497
1498impl std::fmt::Display for BuildError {
1499    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1500        match self {
1501            BuildError::SettingsMissing => write!(
1502                f,
1503                "umbral: App::builder() requires Settings; call .settings(Settings::from_env()?) before .build()"
1504            ),
1505            BuildError::BackendDetect(err) => write!(f, "{err}"),
1506            BuildError::SystemCheckFailed { findings } => {
1507                writeln!(f, "umbral: {} system check(s) failed:", findings.len())?;
1508                for finding in findings {
1509                    write!(f, "  - [{}] {}", finding.check_id, finding.message)?;
1510                    if let Some(hint) = &finding.hint {
1511                        write!(f, " (hint: {hint})")?;
1512                    }
1513                    writeln!(f)?;
1514                }
1515                Ok(())
1516            }
1517            BuildError::DefaultPoolMissing => write!(
1518                f,
1519                "umbral: App::builder() requires a default DB pool; call .database(\"default\", umbral::db::connect(&url).await?) before .build()"
1520            ),
1521            BuildError::DependencyNotFound { plugin, missing } => write!(
1522                f,
1523                "umbral: plugin `{plugin}` depends on `{missing}`, which isn't registered; \
1524                 call .plugin({missing}::default()) on the builder"
1525            ),
1526            BuildError::PluginCycle { names } => {
1527                write!(f, "umbral: plugin dependency cycle: {}", names.join(" -> "))
1528            }
1529            BuildError::DuplicatePluginName { name } => write!(
1530                f,
1531                "umbral: two plugins both report name `{name}`; plugin names are unique keys \
1532                 (migration tracking, dependency graph)"
1533            ),
1534            BuildError::ReservedPluginName => write!(
1535                f,
1536                "umbral: the plugin name `app` is reserved for models registered via \
1537                 .model::<T>(); pick a different name"
1538            ),
1539            BuildError::PluginOnReady { plugin, source } => {
1540                write!(f, "umbral: plugin `{plugin}`'s on_ready failed: {source}")
1541            }
1542            BuildError::TemplatesInit(err) => {
1543                write!(f, "umbral: templates engine failed to initialise: {err}")
1544            }
1545            BuildError::PluginDatabaseAlias { plugin, alias } => write!(
1546                f,
1547                "umbral: plugin `{plugin}` requested database alias `{alias}`, which isn't \
1548                 registered; call .database(\"{alias}\", pool) on the builder before .build()"
1549            ),
1550            BuildError::CrossDatabaseForeignKey {
1551                model,
1552                field,
1553                model_db,
1554                target_db,
1555            } => write!(
1556                f,
1557                "umbral: model `{model}` (database `{model_db}`) has a foreign key \
1558                 `{field}` to a model on database `{target_db}`. A FOREIGN KEY \
1559                 constraint can't span databases. Either route both models to the \
1560                 same database, or mark the field `#[umbral(db_constraint = false)]` \
1561                 to keep it a logical-only relation (joins / select_related still \
1562                 work; no physical constraint is emitted)."
1563            ),
1564            BuildError::DatabaseBackendMismatch {
1565                url_backend,
1566                pool_backend,
1567            } => write!(
1568                f,
1569                "umbral: settings.database_url names backend `{url_backend}`, but the \
1570                 default pool passed to .database(...) is a `{pool_backend}` pool. \
1571                 Either change UMBRAL_DATABASE_URL to match the pool, or open the pool \
1572                 against a URL whose scheme matches umbral::db::connect."
1573            ),
1574            BuildError::DuplicateStaticNamespace {
1575                namespace,
1576                first_plugin,
1577                second_plugin,
1578            } => write!(
1579                f,
1580                "umbral: plugins `{first_plugin}` and `{second_plugin}` both declare the static \
1581                 namespace `{namespace}` via static_dirs(); namespaces must be unique \
1582                 (they key the /static/<namespace>/ URL and the static_root/<namespace>/ \
1583                 collected-asset dir). Rename one plugin's namespace."
1584            ),
1585        }
1586    }
1587}
1588
1589impl std::error::Error for BuildError {}