Skip to main content

apiplant_server/
lib.rs

1//! # apiplant-server
2//!
3//! Turns a loaded [`App`] into a running HTTP service on [`ntex`]:
4//!
5//! * generic CRUD routes for every resource (`<base>/<resource>[/<id>]`),
6//! * built-in auth routes (`<base>/auth/...`),
7//! * one route per loaded function (`<base>/functions/<name>`),
8//! * [lifecycle hooks](hooks) running functions around each CRUD operation,
9//! * the [admin](admin) dashboard, embedded in the binary and served at
10//!   `/admin/` for every app unless `[admin] enabled = false`,
11//! * the app's `public/` directory served at the site root, with a 404 page,
12//! * TLS inferred from the app's `https/` directory.
13
14/// Build the whole `ntex` application from an [`AppState`].
15///
16/// A macro rather than a function because the type of a fully-assembled `ntex`
17/// app is a tower of generics that can't reasonably be written down. Every
18/// route the server answers is registered here, and only here — `run` and the
19/// tests both go through it, so what the tests exercise is what ships.
20///
21/// Order matters: the dashboard and the public site are registered *before* the
22/// API scope, so their literal paths beat its generic `/{resource}` match. Only
23/// paths that name a real file get a route, which is why `/products` still
24/// reaches the API while `/about.html` reaches the static site.
25macro_rules! build_app {
26    ($state:expr) => {{
27        let state = $state.clone();
28        let config = &state.app.config;
29        let domain = config.server.domain.clone();
30        let statics = state.statics.clone();
31
32        // An empty `base_path` means "mount the API at the root" — but ntex's
33        // `scope("")` matches nothing at all, so it has to be spelled `/`.
34        let base_path = match config.server.base_path.as_str() {
35            "" => "/",
36            path => path,
37        };
38        let mut scope = $crate::ntex_web::scope(base_path);
39        if let Some(g) = $crate::host_guard(&domain) {
40            scope = scope.guard(g);
41        }
42        // Docs routes (literal segments) are registered before the generic
43        // `/{resource}` routes so they win.
44        if config.docs.enabled {
45            scope = scope
46                .route(
47                    "/openapi.json",
48                    $crate::ntex_web::get().to($crate::openapi_spec),
49                )
50                .route(
51                    config.docs.path.as_str(),
52                    $crate::ntex_web::get().to($crate::docs_page),
53                );
54        }
55        let mut scope = scope
56            .route("/_health", $crate::ntex_web::get().to($crate::health))
57            .route(
58                "/auth/register",
59                $crate::ntex_web::post().to($crate::auth_routes::register),
60            )
61            .route(
62                "/auth/login",
63                $crate::ntex_web::post().to($crate::auth_routes::login),
64            )
65            .route(
66                "/auth/me",
67                $crate::ntex_web::get().to($crate::auth_routes::me),
68            )
69            .route(
70                "/auth/apikeys",
71                $crate::ntex_web::post().to($crate::auth_routes::create_api_key),
72            );
73
74        // The flows that reach somebody through their mailbox exist only where
75        // this app can actually send mail. An unmounted route answers 404,
76        // which is the honest answer: there is no password reset here. The
77        // admin manifest carries the same three facts, so no interface offers a
78        // button that would land on one.
79        if state.invitations_enabled() {
80            scope = scope
81                .route(
82                    "/auth/invitations",
83                    $crate::ntex_web::post().to($crate::email_auth::create_invitation),
84                )
85                .route(
86                    "/auth/invitations/{token}",
87                    $crate::ntex_web::get().to($crate::email_auth::preview_invitation),
88                )
89                .route(
90                    "/auth/invitations/{token}/accept",
91                    $crate::ntex_web::post().to($crate::email_auth::accept_invitation),
92                );
93        }
94        if state.requires_email_verification() {
95            scope = scope
96                .route(
97                    "/auth/verify-email",
98                    $crate::ntex_web::post().to($crate::email_auth::verify_email),
99                )
100                .route(
101                    "/auth/verify-email/resend",
102                    $crate::ntex_web::post().to($crate::email_auth::resend_verification),
103                );
104        }
105        if state.password_reset_enabled() {
106            scope = scope
107                .route(
108                    "/auth/password/forgot",
109                    $crate::ntex_web::post().to($crate::email_auth::forgot_password),
110                )
111                .route(
112                    "/auth/password/reset",
113                    $crate::ntex_web::post().to($crate::email_auth::reset_password),
114                );
115        }
116
117        // Signing in with somebody else's account exists only where a
118        // provider is configured. The `GET` pair is a browser following a link
119        // — a redirect out and a redirect back — and the `POST` pair is the
120        // same handshake for a front end that would rather hold the browser
121        // itself; see `oauth_routes`.
122        if state.oauth_enabled() {
123            scope = scope
124                .route(
125                    "/auth/oauth",
126                    $crate::ntex_web::get().to($crate::oauth_routes::providers),
127                )
128                .route(
129                    "/auth/oauth/{provider}/start",
130                    $crate::ntex_web::get().to($crate::oauth_routes::start_redirect),
131                )
132                .route(
133                    "/auth/oauth/{provider}/start",
134                    $crate::ntex_web::post().to($crate::oauth_routes::start_json),
135                )
136                .route(
137                    "/auth/oauth/{provider}/callback",
138                    $crate::ntex_web::get().to($crate::oauth_routes::callback_redirect),
139                )
140                .route(
141                    "/auth/oauth/{provider}/callback",
142                    $crate::ntex_web::post().to($crate::oauth_routes::callback_json),
143                )
144                .route(
145                    "/auth/oauth/{provider}",
146                    $crate::ntex_web::delete().to($crate::oauth_routes::unlink),
147                );
148        }
149
150        // Billing exists only where a provider does. The `billing_*`
151        // resources are absent in the same case, so an app that takes no
152        // money has neither the endpoints nor the tables.
153        if state.payments_enabled() {
154            scope = scope
155                .route(
156                    "/billing/config",
157                    $crate::ntex_web::get().to($crate::billing::config),
158                )
159                .route(
160                    "/billing/checkout",
161                    $crate::ntex_web::post().to($crate::billing::checkout),
162                )
163                .route(
164                    "/billing/portal",
165                    $crate::ntex_web::post().to($crate::billing::portal),
166                )
167                // Stripe's own deliveries. Not authenticated in the ordinary
168                // sense — the body carries a signature — and mounted even
169                // without a `webhook_secret`, where it refuses everything and
170                // says so, rather than 404ing and leaving an operator to
171                // wonder which half is misconfigured.
172                .route(
173                    "/billing/webhook",
174                    $crate::ntex_web::post().to($crate::billing::webhook),
175                );
176        }
177
178        // The assistant exists only where a provider does, like billing and
179        // like the mailbox flows: an app with no `[ai]` section has no
180        // endpoint to 404 on and no button offering one.
181        if state.ai_enabled() {
182            scope = scope
183                .route(
184                    "/ai/config",
185                    $crate::ntex_web::get().to($crate::ai_routes::config),
186                )
187                .route(
188                    "/ai/agents/{name}/chat",
189                    $crate::ntex_web::post().to($crate::agent_routes::chat),
190                )
191                .route(
192                    "/ai/chat",
193                    $crate::ntex_web::post().to($crate::ai_routes::chat),
194                );
195        }
196
197        let mut scope = scope
198            // Literal `functions` segment is registered before the generic
199            // resource routes so it wins over `/{resource}/{id}`.
200            // The streaming form is registered first: `/functions/{name}`
201            // would otherwise match `/functions/summarise/stream` with a name
202            // of `summarise` and lose the suffix.
203            .route(
204                "/functions/{name}/stream",
205                $crate::ntex_web::route().to($crate::function_routes::stream),
206            )
207            .route(
208                "/functions/{name}",
209                $crate::ntex_web::route().to($crate::function_routes::invoke),
210            )
211            .service(
212                $crate::ntex_web::resource("/{resource}")
213                    .route($crate::ntex_web::get().to($crate::crud::list))
214                    .route($crate::ntex_web::post().to($crate::crud::create)),
215            )
216            .service(
217                $crate::ntex_web::resource("/{resource}/{id}")
218                    .route($crate::ntex_web::get().to($crate::crud::get))
219                    .route($crate::ntex_web::patch().to($crate::crud::update))
220                    .route($crate::ntex_web::put().to($crate::crud::update))
221                    .route($crate::ntex_web::delete().to($crate::crud::delete)),
222            )
223            // Nested has_many: GET /parent/{id}/child
224            .route(
225                "/{parent}/{id}/{child}",
226                $crate::ntex_web::get().to($crate::crud::nested_list),
227            );
228
229        // With the API mounted at the root, its scope swallows every unmatched
230        // path, so the 404 page has to be its default too — not just the app's.
231        if statics.not_found_page.is_some() {
232            scope = scope.default_service($crate::ntex_web::to($crate::not_found_route));
233        }
234
235        let mut app = $crate::ntex_web::App::new().state(state.clone());
236
237        // Root-level routes answer for the configured domain only, exactly as
238        // the API scope does.
239        macro_rules! guarded {
240            ($path:expr) => {{
241                let resource = $crate::ntex_web::resource($path);
242                match $crate::host_guard(&domain) {
243                    Some(g) => resource.guard(g),
244                    None => resource,
245                }
246            }};
247        }
248
249        if let Some(admin_path) = &statics.admin_path {
250            app = app
251                .service(
252                    guarded!(format!("{admin_path}/"))
253                        .route($crate::ntex_web::get().to($crate::admin_index)),
254                )
255                .service(
256                    guarded!(format!("{admin_path}/{{path:.*}}"))
257                        .route($crate::ntex_web::get().to($crate::admin_asset)),
258                )
259                // `/admin` without the slash would otherwise 404; the page loads
260                // its assets relatively, so it has to resolve as a directory.
261                .service(
262                    guarded!(admin_path.as_str())
263                        .route($crate::ntex_web::get().to($crate::admin_redirect)),
264                );
265        }
266
267        for route in &statics.public_routes {
268            app = app.service(
269                guarded!(route.as_str()).route($crate::ntex_web::get().to($crate::public_asset)),
270            );
271        }
272
273        app = app.service(scope);
274        if statics.not_found_page.is_some() {
275            app = app.default_service($crate::ntex_web::to($crate::not_found_route));
276        }
277        app
278    }};
279}
280
281/// A `Host:` guard matching any of the configured domains, or `None` when no
282/// domains are configured and every host should be answered.
283pub(crate) fn host_guard(domains: &[String]) -> Option<ntex_guard::AnyGuard> {
284    if domains.is_empty() {
285        return None;
286    }
287    Some(ntex_guard::AnyGuard(
288        domains
289            .iter()
290            .map(|d| Box::new(ntex_guard::Host(d.clone())) as Box<dyn ntex_guard::Guard>)
291            .collect(),
292    ))
293}
294
295pub mod access;
296pub mod admin;
297mod agent_routes;
298mod ai_routes;
299mod auth_routes;
300mod banner;
301mod billing;
302pub mod builtins;
303pub mod cabi;
304mod crud;
305pub mod email_auth;
306mod emails;
307mod function_routes;
308pub mod functions;
309pub mod hooks;
310mod oauth_routes;
311mod openapi;
312mod response;
313mod sse;
314mod state;
315#[cfg(test)]
316mod tests;
317
318use std::sync::Arc;
319use std::{fs, path::Component, path::Path, path::PathBuf};
320
321use apiplant_auth::Authenticator;
322use apiplant_core::{App, TlsPaths};
323use apiplant_db::Db;
324use ntex::web::{self, HttpRequest, HttpResponse, HttpServer};
325
326// Re-exported under crate-local names so `build_app!` can name them absolutely
327// and expand anywhere in the crate, tests included.
328pub(crate) use ntex::web as ntex_web;
329pub(crate) use ntex::web::guard as ntex_guard;
330use uuid::Uuid;
331
332use functions::FunctionRegistry;
333use state::{AppState, Statics};
334
335async fn health() -> HttpResponse {
336    HttpResponse::Ok().json(&serde_json::json!({ "status": "ok", "framework": "apiplant" }))
337}
338
339/// Serve the pre-rendered OpenAPI document.
340async fn openapi_spec(state: web::types::State<AppState>) -> HttpResponse {
341    HttpResponse::Ok()
342        .content_type("application/json")
343        .body(state.openapi_json.as_str().to_owned())
344}
345
346/// Serve the Swagger UI page.
347async fn docs_page(state: web::types::State<AppState>) -> HttpResponse {
348    HttpResponse::Ok()
349        .content_type("text/html; charset=utf-8")
350        .body(state.docs_html.as_str().to_owned())
351}
352
353async fn admin_index(state: web::types::State<AppState>) -> HttpResponse {
354    serve_admin(&state, "index.html")
355}
356
357async fn admin_asset(
358    state: web::types::State<AppState>,
359    path: web::types::Path<String>,
360) -> HttpResponse {
361    let path = path.into_inner();
362    serve_admin(&state, &path)
363}
364
365/// Serve one file of the dashboard.
366///
367/// Everything comes out of the binary: the files from the embedded build, the
368/// manifest from memory — it describes *this* app, and is built on boot. There
369/// is no directory to generate and none to go stale.
370fn serve_admin(state: &AppState, requested: &str) -> HttpResponse {
371    let requested = requested.trim_start_matches('/');
372
373    if requested == admin::MANIFEST_FILE {
374        return HttpResponse::Ok()
375            .content_type("application/json")
376            .body(state.admin_manifest.as_str().to_owned());
377    }
378
379    match admin::asset(requested) {
380        Some(bytes) => HttpResponse::Ok()
381            .content_type(apiplant_assets::content_type(requested))
382            .body(bytes.into_owned()),
383        None => HttpResponse::NotFound().finish(),
384    }
385}
386
387/// Serve a file from the app's `public/` directory.
388///
389/// Routes are registered per file at boot, so the path always names something
390/// that existed then; it is re-resolved here so edits are picked up without a
391/// restart, and a file deleted since boot answers with the 404 page.
392async fn public_asset(state: web::types::State<AppState>, req: HttpRequest) -> HttpResponse {
393    let Some(root) = state.statics.public_dir.as_deref() else {
394        return not_found(&state);
395    };
396    serve_file(root, req.path()).unwrap_or_else(|| not_found(&state))
397}
398
399/// Anything that matched no route at all: the app's 404 page, or a bare 404.
400async fn not_found_route(state: web::types::State<AppState>) -> HttpResponse {
401    not_found(&state)
402}
403
404fn not_found(state: &AppState) -> HttpResponse {
405    let Some(page) = state.statics.not_found_page.as_deref() else {
406        return HttpResponse::NotFound().finish();
407    };
408    match fs::read(page) {
409        Ok(bytes) => HttpResponse::NotFound()
410            .content_type(content_type_for(page))
411            .body(bytes),
412        Err(error) => {
413            tracing::error!(path = %page.display(), error = %error, "failed to read 404 page");
414            HttpResponse::NotFound().finish()
415        }
416    }
417}
418
419/// Read a file under `root`, or `None` when it isn't there.
420fn serve_file(root: &Path, requested: &str) -> Option<HttpResponse> {
421    let path = resolve_static_path(root, requested)?;
422    match fs::read(&path) {
423        Ok(bytes) => Some(
424            HttpResponse::Ok()
425                .content_type(content_type_for(&path))
426                .body(bytes),
427        ),
428        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
429        Err(error) => {
430            tracing::error!(path = %path.display(), error = %error, "failed to read static file");
431            Some(HttpResponse::InternalServerError().finish())
432        }
433    }
434}
435
436fn resolve_static_path(root: &Path, requested: &str) -> Option<PathBuf> {
437    let mut path = root.to_path_buf();
438    let requested = requested.trim_matches('/');
439
440    if requested.is_empty() {
441        path.push("index.html");
442        return Some(path);
443    }
444
445    for component in Path::new(requested).components() {
446        match component {
447            Component::Normal(segment) => path.push(segment),
448            Component::CurDir => {}
449            _ => return None,
450        }
451    }
452
453    if path.is_dir() {
454        path.push("index.html");
455    }
456    Some(path)
457}
458
459fn content_type_for(path: &Path) -> &'static str {
460    apiplant_assets::content_type(&path.to_string_lossy())
461}
462
463/// `/admin` → `/admin/`, so relative asset URLs resolve.
464async fn admin_redirect(req: HttpRequest) -> HttpResponse {
465    HttpResponse::PermanentRedirect()
466        .header("location", format!("{}/", req.path()))
467        .finish()
468}
469
470/// The route patterns one public file answers on.
471///
472/// A file is served at its own path; an `index.html` additionally answers for
473/// the directory holding it, with and without the trailing slash. Returns
474/// nothing for names ntex would read as a path pattern (`{`, `}`) or that would
475/// escape the root — those are skipped rather than mis-registered.
476fn public_routes(relative: &str) -> Vec<String> {
477    if relative
478        .split('/')
479        .any(|segment| segment.is_empty() || segment.contains(['{', '}']) || segment == "..")
480    {
481        tracing::warn!(
482            file = relative,
483            "skipping public file: its name can't be a route"
484        );
485        return Vec::new();
486    }
487
488    let mut routes = vec![format!("/{relative}")];
489    if let Some(directory) = relative.strip_suffix("index.html") {
490        let directory = directory.trim_end_matches('/');
491        if directory.is_empty() {
492            routes.push("/".to_string());
493        } else {
494            routes.push(format!("/{directory}/"));
495            routes.push(format!("/{directory}"));
496        }
497    }
498    routes
499}
500
501/// Every file under `root`, as site-root-relative paths (`css/app.css`).
502///
503/// Used to register one route per public file, which is what lets a static site
504/// share the root with the API: an explicit `/about.html` route is matched
505/// before the generic `/{resource}` CRUD route, while `/products` still reaches
506/// the API because no such file exists.
507fn walk_public(root: &Path, prefix: &str, into: &mut Vec<String>) {
508    let entries = match fs::read_dir(root) {
509        Ok(entries) => entries,
510        Err(error) => {
511            tracing::error!(path = %root.display(), error = %error, "failed to read public directory");
512            return;
513        }
514    };
515    for entry in entries.flatten() {
516        let name = entry.file_name().to_string_lossy().into_owned();
517        let relative = if prefix.is_empty() {
518            name
519        } else {
520            format!("{prefix}/{name}")
521        };
522        if entry.path().is_dir() {
523            walk_public(&entry.path(), &relative, into);
524        } else {
525            into.push(relative);
526        }
527    }
528}
529
530/// Boot the server for a loaded app and serve until shut down.
531pub async fn run(app: App) -> anyhow::Result<()> {
532    run_with(app, Options::default()).await
533}
534
535/// How to boot.
536#[derive(Debug, Clone, Default)]
537pub struct Options {
538    /// Load the app's `seed/` directory after migrating. Off by default: a
539    /// fixture belongs to a fresh database and a development machine, not to
540    /// every restart of a production server.
541    pub seed: bool,
542}
543
544/// Boot the server for a loaded app, with boot-time options.
545pub async fn run_with(app: App, options: Options) -> anyhow::Result<()> {
546    // 1. Database + migrations.
547    let db_url = app.config.database.resolved_url();
548    tracing::info!("connecting to database");
549    let db = Db::connect(&db_url, app.config.database.max_connections).await?;
550    if app.config.database.auto_migrate {
551        tracing::info!("running migrations");
552        apiplant_db::migrate(db.connection(), &app).await?;
553    }
554    if options.seed {
555        // After the migrations, because the fixture needs its tables — and
556        // before anything is served, because a request that arrives mid-seed
557        // would see half a fixture.
558        let report = apiplant_db::seed::seed(db.connection(), &app).await?;
559        if report.is_empty() {
560            tracing::warn!("--seed was given but there is no seed/ directory to load");
561        } else {
562            tracing::info!(
563                inserted = report.inserted(),
564                already_present = report.skipped(),
565                "seeded"
566            );
567        }
568    }
569
570    // 2. Authenticator (ephemeral secret if none configured).
571    let secret = if app.config.auth.jwt_secret.is_empty() {
572        tracing::warn!(
573            "auth.jwt_secret is empty — using an ephemeral secret; sessions won't survive a restart"
574        );
575        format!("{}{}", Uuid::new_v4(), Uuid::new_v4()).into_bytes()
576    } else {
577        app.config.auth.jwt_secret.clone().into_bytes()
578    };
579    let authr = Authenticator::new(secret, app.config.auth.session_ttl_secs);
580
581    // 2b. Optional services a function can reach: the email provider and the
582    //     cache. Both are built here, once, and shared by every worker — and
583    //     both fail the boot when the app asked for one it can't have, rather
584    //     than at the first send or the first lookup.
585    let mailer = apiplant_email::Mailer::from_config(&app.config.email)?;
586    match &mailer {
587        Some(mailer) => tracing::info!(
588            "  email -> {} (from {})",
589            mailer.provider().as_str(),
590            app.config.email.from
591        ),
592        None => tracing::debug!("no email provider configured"),
593    }
594
595    let cache = apiplant_cache::Cache::connect(&app.config.cache).await?;
596    match &cache {
597        Some(_) => tracing::info!(
598            "  cache -> redis (prefix {:?})",
599            app.config.cache.prefix.as_str()
600        ),
601        None => tracing::debug!("no cache configured"),
602    }
603
604    let ai = apiplant_ai::Ai::from_config(&app.config.ai)?;
605    let agent_ais = app
606        .agents
607        .values()
608        .filter_map(|agent| {
609            agent.ai.as_ref().map(|_| {
610                apiplant_ai::Ai::from_config(&agent.merged_ai_config(&app.config.ai))
611                    .map(|ai| (agent.meta.name.clone(), ai))
612            })
613        })
614        .collect::<Result<Vec<_>, _>>()?
615        .into_iter()
616        .filter_map(|(name, ai)| ai.map(|ai| (name, ai)))
617        .collect();
618    match &ai {
619        Some(ai) => tracing::info!(
620            "  ai -> {} ({} at {})",
621            ai.provider().as_str(),
622            match ai.model() {
623                "" => "the server's own model",
624                model => model,
625            },
626            ai.url()
627        ),
628        None => tracing::debug!("no ai provider configured"),
629    }
630
631    // A buyer Stripe returns to needs somewhere to land, and this crate is
632    // the only thing that knows where that is: the dashboard's billing
633    // screen, or the app's own origin when the dashboard is switched off.
634    let billing_landing = match app.config.admin.enabled {
635        true => format!(
636            "{}{}/#/billing",
637            app.config.server.public_origin(),
638            app.config.admin.path.trim_end_matches('/')
639        ),
640        false => app.config.server.public_origin(),
641    };
642    let payments =
643        apiplant_payments::Payments::from_config(&app.config.payments, &billing_landing)?;
644    match &payments {
645        Some(payments) => tracing::info!(
646            "  payments -> {} ({}, automatic tax {})",
647            payments.provider().as_str(),
648            app.config.payments.default_currency(),
649            match app.config.payments.automatic_tax {
650                true => "on",
651                false => "off",
652            }
653        ),
654        None => tracing::debug!("no payment provider configured"),
655    }
656
657    // Sign-in with somebody else's account. Both failures here are startup
658    // failures on purpose: a provider missing its secret, or an app that
659    // replaced `oauth_connection` and dropped a column it needs, would
660    // otherwise surface as a 500 in front of the first person to press the
661    // button — and be discovered by them rather than by whoever deployed it.
662    oauth_routes::check_resources(&app).map_err(apiplant_core::Error::Message)?;
663    let callback_base = format!(
664        "{}{}/auth/oauth",
665        app.config.server.public_origin(),
666        app.config.server.base_path.trim_end_matches('/'),
667    );
668    let oauth = apiplant_oauth::Providers::from_config(&app.config.oauth, &callback_base)
669        .map_err(|e| apiplant_core::Error::Message(e.to_string()))?;
670    match &oauth {
671        Some(providers) => {
672            for provider in providers.iter() {
673                tracing::info!(
674                    "  oauth {} -> {}/auth/oauth/{}/start  (redirect URI: {})",
675                    provider.label,
676                    app.config.server.base_path,
677                    provider.key,
678                    provider.redirect_uri,
679                );
680            }
681        }
682        None => tracing::debug!("no oauth providers configured"),
683    }
684
685    // 3. Load dynamic functions.
686    let registry = FunctionRegistry::load(&app);
687    for f in registry.iter() {
688        // A `Private` function has no route — it exists to be called from a
689        // hook — so don't advertise one it would answer 404 on.
690        if f.manifest.visibility == apiplant_abi::Visibility::Private {
691            tracing::info!("  fn {} (private — no endpoint)", f.manifest.name);
692        } else {
693            tracing::info!(
694                "  fn {} -> {}/functions/{}",
695                f.manifest.name,
696                app.config.server.base_path,
697                f.manifest.name
698            );
699        }
700    }
701
702    // 4. Report the resource hooks, loudly flagging any that can't resolve —
703    //    a missing hook function fails its requests closed at runtime.
704    for resource in app.resources.values() {
705        for (event, function) in resource.hooks.iter() {
706            if registry.get(function).is_some() {
707                tracing::info!(
708                    "  hook {}.{} -> {}",
709                    resource.meta.name,
710                    event.as_str(),
711                    function
712                );
713            } else {
714                tracing::error!(
715                    resource = %resource.meta.name,
716                    hook = event.as_str(),
717                    function = function,
718                    "hook function is not loaded — this resource's {} requests will fail with 500",
719                    event.action()
720                );
721            }
722        }
723        for (event, function) in resource.hooks.auth_iter() {
724            if registry.get(function).is_some() {
725                tracing::info!("  hook auth.{} -> {}", event.as_str(), function);
726            } else {
727                tracing::error!(
728                    hook = event.as_str(),
729                    function = function,
730                    "auth hook function is not loaded — {} requests will fail with 500",
731                    event.action()
732                );
733            }
734        }
735    }
736
737    // 5. Generate the OpenAPI document + Swagger UI (once; static per boot).
738    let base_path = app.config.server.base_path.clone();
739    let spec_url = format!("{base_path}/openapi.json");
740    let spec = openapi::build(&app, &registry, mailer.is_some());
741    let openapi_json = serde_json::to_string(&spec).unwrap_or_else(|_| "{}".to_string());
742    let docs_html = openapi::swagger_ui_html(&spec_url, &app.docs_title());
743    if app.config.docs.enabled {
744        tracing::info!(
745            "  docs -> {base_path}{}  (spec: {spec_url})",
746            app.config.docs.path
747        );
748    }
749
750    // 6. Assemble shared state and pull out what the closure needs.
751    let host = app.config.server.host.clone();
752    let port = app.config.server.port;
753    let banner_docs_path = app
754        .config
755        .docs
756        .enabled
757        .then(|| app.config.docs.path.clone());
758    let banner_domains = app.config.server.domain.clone();
759    let banner_name = app.display_name();
760    let workers = app.config.server.workers;
761    let tls = app.tls.clone();
762
763    // 7. Work out what is served alongside the API — the dashboard, the public
764    //    site, the 404 page — and build the dashboard's manifest.
765    //
766    //    The dashboard ships inside the binary, so every app has one without
767    //    generating anything; an `admin/` directory in the app (from `apiplant
768    //    admin`) overrides the embedded build file for file. Either way the
769    //    manifest is derived here, from the app being served, and the dashboard
770    //    talks to its own origin — no CORS, and no rebuild after a model change.
771    let statics = Statics::resolve(&app);
772    let banner_admin_path = statics.admin_path.clone();
773    let banner_site = !statics.public_routes.is_empty();
774    let admin_manifest = match &statics.admin_path {
775        Some(path) => {
776            tracing::info!("  admin -> {path}/");
777            admin::manifest_json(&app, &registry, base_path.clone(), mailer.is_some()).unwrap_or_else(|error| {
778                tracing::error!(%error, "failed to build the admin manifest — the dashboard will not load");
779                "{}".to_string()
780            })
781        }
782        None => String::new(),
783    };
784    if let Some(dir) = &statics.public_dir {
785        tracing::info!(
786            routes = statics.public_routes.len(),
787            "  public -> /  (from {})",
788            dir.display()
789        );
790    }
791    if let Some(page) = &statics.not_found_page {
792        tracing::info!("  404 -> {}", page.display());
793    }
794
795    let state = AppState {
796        app: Arc::new(app),
797        db,
798        auth: authr,
799        functions: Arc::new(registry),
800        mailer,
801        cache,
802        payments,
803        ai,
804        oauth: oauth.map(Arc::new),
805        agent_ais: Arc::new(agent_ais),
806        statics: Arc::new(statics),
807        admin_manifest: Arc::new(admin_manifest),
808        openapi_json: Arc::new(openapi_json),
809        docs_html: Arc::new(docs_html),
810    };
811
812    let base_path_log = base_path.clone();
813    let mut server = HttpServer::new(move || build_app!(state));
814
815    if let Some(w) = workers {
816        server = server.workers(w);
817    }
818
819    let addr = format!("{host}:{port}");
820    let scheme = if tls.is_some() { "https" } else { "http" };
821    let server = match tls {
822        Some(paths) => server.bind_rustls(&addr, load_tls(&paths)?)?,
823        None => server.bind(&addr)?,
824    };
825
826    tracing::info!("apiplant listening on {scheme}://{addr}{base_path_log}");
827    banner::Banner {
828        name: banner_name,
829        scheme,
830        addr: addr.clone(),
831        base_path: base_path_log.clone(),
832        docs_path: banner_docs_path,
833        admin_path: banner_admin_path,
834        site: banner_site,
835        domains: banner_domains,
836    }
837    .print();
838    server.run().await?;
839    Ok(())
840}
841
842/// Build a rustls server config from PEM cert + key files.
843fn load_tls(paths: &TlsPaths) -> anyhow::Result<rustls::ServerConfig> {
844    use std::io::BufReader;
845
846    // Install a default crypto provider once (ring); ignore "already set".
847    let _ = rustls::crypto::ring::default_provider().install_default();
848
849    let mut cert_reader = BufReader::new(std::fs::File::open(&paths.cert)?);
850    let certs = rustls_pemfile::certs(&mut cert_reader).collect::<Result<Vec<_>, _>>()?;
851
852    let mut key_reader = BufReader::new(std::fs::File::open(&paths.key)?);
853    let key = rustls_pemfile::private_key(&mut key_reader)?
854        .ok_or_else(|| anyhow::anyhow!("no private key in {}", paths.key.display()))?;
855
856    let config = rustls::ServerConfig::builder()
857        .with_no_client_auth()
858        .with_single_cert(certs, key)?;
859    Ok(config)
860}
861
862#[cfg(test)]
863mod route_tests {
864    use super::*;
865
866    #[test]
867    fn an_index_answers_for_its_directory_too() {
868        assert_eq!(public_routes("index.html"), ["/index.html", "/"]);
869        assert_eq!(
870            public_routes("guide/index.html"),
871            ["/guide/index.html", "/guide/", "/guide"]
872        );
873        assert_eq!(public_routes("css/app.css"), ["/css/app.css"]);
874    }
875
876    #[test]
877    fn names_that_cannot_be_routes_are_skipped() {
878        assert!(public_routes("weird{name}.html").is_empty());
879        assert!(public_routes("../escape.html").is_empty());
880    }
881
882    #[test]
883    fn static_paths_resolve_under_the_root_and_never_above_it() {
884        let root = Path::new("/srv/app/public");
885        assert_eq!(
886            resolve_static_path(root, "/css/app.css"),
887            Some(root.join("css/app.css"))
888        );
889        assert_eq!(
890            resolve_static_path(root, "/"),
891            Some(root.join("index.html"))
892        );
893        assert_eq!(resolve_static_path(root, "/../main.toml"), None);
894    }
895}