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            // Literal `functions` segment is registered before the generic
74            // resource routes so it wins over `/{resource}/{id}`.
75            .route(
76                "/functions/{name}",
77                $crate::ntex_web::route().to($crate::function_routes::invoke),
78            )
79            .service(
80                $crate::ntex_web::resource("/{resource}")
81                    .route($crate::ntex_web::get().to($crate::crud::list))
82                    .route($crate::ntex_web::post().to($crate::crud::create)),
83            )
84            .service(
85                $crate::ntex_web::resource("/{resource}/{id}")
86                    .route($crate::ntex_web::get().to($crate::crud::get))
87                    .route($crate::ntex_web::patch().to($crate::crud::update))
88                    .route($crate::ntex_web::put().to($crate::crud::update))
89                    .route($crate::ntex_web::delete().to($crate::crud::delete)),
90            )
91            // Nested has_many: GET /parent/{id}/child
92            .route(
93                "/{parent}/{id}/{child}",
94                $crate::ntex_web::get().to($crate::crud::nested_list),
95            );
96
97        // With the API mounted at the root, its scope swallows every unmatched
98        // path, so the 404 page has to be its default too — not just the app's.
99        if statics.not_found_page.is_some() {
100            scope = scope.default_service($crate::ntex_web::to($crate::not_found_route));
101        }
102
103        let mut app = $crate::ntex_web::App::new().state(state.clone());
104
105        // Root-level routes answer for the configured domain only, exactly as
106        // the API scope does.
107        macro_rules! guarded {
108            ($path:expr) => {{
109                let resource = $crate::ntex_web::resource($path);
110                match $crate::host_guard(&domain) {
111                    Some(g) => resource.guard(g),
112                    None => resource,
113                }
114            }};
115        }
116
117        if let Some(admin_path) = &statics.admin_path {
118            app = app
119                .service(
120                    guarded!(format!("{admin_path}/"))
121                        .route($crate::ntex_web::get().to($crate::admin_index)),
122                )
123                .service(
124                    guarded!(format!("{admin_path}/{{path:.*}}"))
125                        .route($crate::ntex_web::get().to($crate::admin_asset)),
126                )
127                // `/admin` without the slash would otherwise 404; the page loads
128                // its assets relatively, so it has to resolve as a directory.
129                .service(
130                    guarded!(admin_path.as_str())
131                        .route($crate::ntex_web::get().to($crate::admin_redirect)),
132                );
133        }
134
135        for route in &statics.public_routes {
136            app = app.service(
137                guarded!(route.as_str()).route($crate::ntex_web::get().to($crate::public_asset)),
138            );
139        }
140
141        app = app.service(scope);
142        if statics.not_found_page.is_some() {
143            app = app.default_service($crate::ntex_web::to($crate::not_found_route));
144        }
145        app
146    }};
147}
148
149/// A `Host:` guard matching any of the configured domains, or `None` when no
150/// domains are configured and every host should be answered.
151pub(crate) fn host_guard(domains: &[String]) -> Option<ntex_guard::AnyGuard> {
152    if domains.is_empty() {
153        return None;
154    }
155    Some(ntex_guard::AnyGuard(
156        domains
157            .iter()
158            .map(|d| Box::new(ntex_guard::Host(d.clone())) as Box<dyn ntex_guard::Guard>)
159            .collect(),
160    ))
161}
162
163pub mod admin;
164mod auth_routes;
165pub mod builtins;
166pub mod cabi;
167mod crud;
168mod function_routes;
169pub mod functions;
170pub mod hooks;
171mod openapi;
172mod response;
173mod state;
174#[cfg(test)]
175mod tests;
176
177use std::sync::Arc;
178use std::{fs, path::Component, path::Path, path::PathBuf};
179
180use apiplant_auth::Authenticator;
181use apiplant_core::{App, TlsPaths};
182use apiplant_db::Db;
183use ntex::web::{self, HttpRequest, HttpResponse, HttpServer};
184
185// Re-exported under crate-local names so `build_app!` can name them absolutely
186// and expand anywhere in the crate, tests included.
187pub(crate) use ntex::web as ntex_web;
188pub(crate) use ntex::web::guard as ntex_guard;
189use uuid::Uuid;
190
191use functions::FunctionRegistry;
192use state::{AppState, Statics};
193
194async fn health() -> HttpResponse {
195    HttpResponse::Ok().json(&serde_json::json!({ "status": "ok", "framework": "apiplant" }))
196}
197
198/// Serve the pre-rendered OpenAPI document.
199async fn openapi_spec(state: web::types::State<AppState>) -> HttpResponse {
200    HttpResponse::Ok()
201        .content_type("application/json")
202        .body(state.openapi_json.as_str().to_owned())
203}
204
205/// Serve the Swagger UI page.
206async fn docs_page(state: web::types::State<AppState>) -> HttpResponse {
207    HttpResponse::Ok()
208        .content_type("text/html; charset=utf-8")
209        .body(state.docs_html.as_str().to_owned())
210}
211
212async fn admin_index(state: web::types::State<AppState>) -> HttpResponse {
213    serve_admin(&state, "index.html")
214}
215
216async fn admin_asset(
217    state: web::types::State<AppState>,
218    path: web::types::Path<String>,
219) -> HttpResponse {
220    let path = path.into_inner();
221    serve_admin(&state, &path)
222}
223
224/// Serve one file of the dashboard.
225///
226/// Everything comes out of the binary: the files from the embedded build, the
227/// manifest from memory — it describes *this* app, and is built on boot. There
228/// is no directory to generate and none to go stale.
229fn serve_admin(state: &AppState, requested: &str) -> HttpResponse {
230    let requested = requested.trim_start_matches('/');
231
232    if requested == admin::MANIFEST_FILE {
233        return HttpResponse::Ok()
234            .content_type("application/json")
235            .body(state.admin_manifest.as_str().to_owned());
236    }
237
238    match admin::asset(requested) {
239        Some(bytes) => HttpResponse::Ok()
240            .content_type(apiplant_assets::content_type(requested))
241            .body(bytes.into_owned()),
242        None => HttpResponse::NotFound().finish(),
243    }
244}
245
246/// Serve a file from the app's `public/` directory.
247///
248/// Routes are registered per file at boot, so the path always names something
249/// that existed then; it is re-resolved here so edits are picked up without a
250/// restart, and a file deleted since boot answers with the 404 page.
251async fn public_asset(state: web::types::State<AppState>, req: HttpRequest) -> HttpResponse {
252    let Some(root) = state.statics.public_dir.as_deref() else {
253        return not_found(&state);
254    };
255    serve_file(root, req.path()).unwrap_or_else(|| not_found(&state))
256}
257
258/// Anything that matched no route at all: the app's 404 page, or a bare 404.
259async fn not_found_route(state: web::types::State<AppState>) -> HttpResponse {
260    not_found(&state)
261}
262
263fn not_found(state: &AppState) -> HttpResponse {
264    let Some(page) = state.statics.not_found_page.as_deref() else {
265        return HttpResponse::NotFound().finish();
266    };
267    match fs::read(page) {
268        Ok(bytes) => HttpResponse::NotFound()
269            .content_type(content_type_for(page))
270            .body(bytes),
271        Err(error) => {
272            tracing::error!(path = %page.display(), error = %error, "failed to read 404 page");
273            HttpResponse::NotFound().finish()
274        }
275    }
276}
277
278/// Read a file under `root`, or `None` when it isn't there.
279fn serve_file(root: &Path, requested: &str) -> Option<HttpResponse> {
280    let path = resolve_static_path(root, requested)?;
281    match fs::read(&path) {
282        Ok(bytes) => Some(
283            HttpResponse::Ok()
284                .content_type(content_type_for(&path))
285                .body(bytes),
286        ),
287        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
288        Err(error) => {
289            tracing::error!(path = %path.display(), error = %error, "failed to read static file");
290            Some(HttpResponse::InternalServerError().finish())
291        }
292    }
293}
294
295fn resolve_static_path(root: &Path, requested: &str) -> Option<PathBuf> {
296    let mut path = root.to_path_buf();
297    let requested = requested.trim_matches('/');
298
299    if requested.is_empty() {
300        path.push("index.html");
301        return Some(path);
302    }
303
304    for component in Path::new(requested).components() {
305        match component {
306            Component::Normal(segment) => path.push(segment),
307            Component::CurDir => {}
308            _ => return None,
309        }
310    }
311
312    if path.is_dir() {
313        path.push("index.html");
314    }
315    Some(path)
316}
317
318fn content_type_for(path: &Path) -> &'static str {
319    apiplant_assets::content_type(&path.to_string_lossy())
320}
321
322/// `/admin` → `/admin/`, so relative asset URLs resolve.
323async fn admin_redirect(req: HttpRequest) -> HttpResponse {
324    HttpResponse::PermanentRedirect()
325        .header("location", format!("{}/", req.path()))
326        .finish()
327}
328
329/// The route patterns one public file answers on.
330///
331/// A file is served at its own path; an `index.html` additionally answers for
332/// the directory holding it, with and without the trailing slash. Returns
333/// nothing for names ntex would read as a path pattern (`{`, `}`) or that would
334/// escape the root — those are skipped rather than mis-registered.
335fn public_routes(relative: &str) -> Vec<String> {
336    if relative
337        .split('/')
338        .any(|segment| segment.is_empty() || segment.contains(['{', '}']) || segment == "..")
339    {
340        tracing::warn!(
341            file = relative,
342            "skipping public file: its name can't be a route"
343        );
344        return Vec::new();
345    }
346
347    let mut routes = vec![format!("/{relative}")];
348    if let Some(directory) = relative.strip_suffix("index.html") {
349        let directory = directory.trim_end_matches('/');
350        if directory.is_empty() {
351            routes.push("/".to_string());
352        } else {
353            routes.push(format!("/{directory}/"));
354            routes.push(format!("/{directory}"));
355        }
356    }
357    routes
358}
359
360/// Every file under `root`, as site-root-relative paths (`css/app.css`).
361///
362/// Used to register one route per public file, which is what lets a static site
363/// share the root with the API: an explicit `/about.html` route is matched
364/// before the generic `/{resource}` CRUD route, while `/products` still reaches
365/// the API because no such file exists.
366fn walk_public(root: &Path, prefix: &str, into: &mut Vec<String>) {
367    let entries = match fs::read_dir(root) {
368        Ok(entries) => entries,
369        Err(error) => {
370            tracing::error!(path = %root.display(), error = %error, "failed to read public directory");
371            return;
372        }
373    };
374    for entry in entries.flatten() {
375        let name = entry.file_name().to_string_lossy().into_owned();
376        let relative = if prefix.is_empty() {
377            name
378        } else {
379            format!("{prefix}/{name}")
380        };
381        if entry.path().is_dir() {
382            walk_public(&entry.path(), &relative, into);
383        } else {
384            into.push(relative);
385        }
386    }
387}
388
389/// Boot the server for a loaded app and serve until shut down.
390pub async fn run(app: App) -> anyhow::Result<()> {
391    // 1. Database + migrations.
392    let db_url = app.config.database.resolved_url();
393    tracing::info!("connecting to database");
394    let db = Db::connect(&db_url, app.config.database.max_connections).await?;
395    if app.config.database.auto_migrate {
396        tracing::info!("running migrations");
397        apiplant_db::migrate(db.connection(), &app).await?;
398    }
399
400    // 2. Authenticator (ephemeral secret if none configured).
401    let secret = if app.config.auth.jwt_secret.is_empty() {
402        tracing::warn!(
403            "auth.jwt_secret is empty — using an ephemeral secret; sessions won't survive a restart"
404        );
405        format!("{}{}", Uuid::new_v4(), Uuid::new_v4()).into_bytes()
406    } else {
407        app.config.auth.jwt_secret.clone().into_bytes()
408    };
409    let authr = Authenticator::new(secret, app.config.auth.session_ttl_secs);
410
411    // 2b. Optional services a function can reach: the email provider and the
412    //     cache. Both are built here, once, and shared by every worker — and
413    //     both fail the boot when the app asked for one it can't have, rather
414    //     than at the first send or the first lookup.
415    let mailer = apiplant_email::Mailer::from_config(&app.config.email)?;
416    match &mailer {
417        Some(mailer) => tracing::info!(
418            "  email -> {} (from {})",
419            mailer.provider().as_str(),
420            app.config.email.from
421        ),
422        None => tracing::debug!("no email provider configured"),
423    }
424
425    let cache = apiplant_cache::Cache::connect(&app.config.cache).await?;
426    match &cache {
427        Some(_) => tracing::info!(
428            "  cache -> redis (prefix {:?})",
429            app.config.cache.prefix.as_str()
430        ),
431        None => tracing::debug!("no cache configured"),
432    }
433
434    // 3. Load dynamic functions.
435    let registry = FunctionRegistry::load(&app);
436    for f in registry.iter() {
437        // A `Private` function has no route — it exists to be called from a
438        // hook — so don't advertise one it would answer 404 on.
439        if f.manifest.visibility == apiplant_abi::Visibility::Private {
440            tracing::info!("  fn {} (private — no endpoint)", f.manifest.name);
441        } else {
442            tracing::info!(
443                "  fn {} -> {}/functions/{}",
444                f.manifest.name,
445                app.config.server.base_path,
446                f.manifest.name
447            );
448        }
449    }
450
451    // 4. Report the resource hooks, loudly flagging any that can't resolve —
452    //    a missing hook function fails its requests closed at runtime.
453    for resource in app.resources.values() {
454        for (event, function) in resource.hooks.iter() {
455            if registry.get(function).is_some() {
456                tracing::info!(
457                    "  hook {}.{} -> {}",
458                    resource.meta.name,
459                    event.as_str(),
460                    function
461                );
462            } else {
463                tracing::error!(
464                    resource = %resource.meta.name,
465                    hook = event.as_str(),
466                    function = function,
467                    "hook function is not loaded — this resource's {} requests will fail with 500",
468                    event.action()
469                );
470            }
471        }
472        for (event, function) in resource.hooks.auth_iter() {
473            if registry.get(function).is_some() {
474                tracing::info!("  hook auth.{} -> {}", event.as_str(), function);
475            } else {
476                tracing::error!(
477                    hook = event.as_str(),
478                    function = function,
479                    "auth hook function is not loaded — {} requests will fail with 500",
480                    event.action()
481                );
482            }
483        }
484    }
485
486    // 5. Generate the OpenAPI document + Swagger UI (once; static per boot).
487    let base_path = app.config.server.base_path.clone();
488    let spec_url = format!("{base_path}/openapi.json");
489    let spec = openapi::build(&app, &registry);
490    let openapi_json = serde_json::to_string(&spec).unwrap_or_else(|_| "{}".to_string());
491    let docs_html = openapi::swagger_ui_html(&spec_url, &app.docs_title());
492    if app.config.docs.enabled {
493        tracing::info!(
494            "  docs -> {base_path}{}  (spec: {spec_url})",
495            app.config.docs.path
496        );
497    }
498
499    // 6. Assemble shared state and pull out what the closure needs.
500    let host = app.config.server.host.clone();
501    let port = app.config.server.port;
502    let workers = app.config.server.workers;
503    let tls = app.tls.clone();
504
505    // 7. Work out what is served alongside the API — the dashboard, the public
506    //    site, the 404 page — and build the dashboard's manifest.
507    //
508    //    The dashboard ships inside the binary, so every app has one without
509    //    generating anything; an `admin/` directory in the app (from `apiplant
510    //    admin`) overrides the embedded build file for file. Either way the
511    //    manifest is derived here, from the app being served, and the dashboard
512    //    talks to its own origin — no CORS, and no rebuild after a model change.
513    let statics = Statics::resolve(&app);
514    let admin_manifest = match &statics.admin_path {
515        Some(path) => {
516            tracing::info!("  admin -> {path}/");
517            admin::manifest_json(&app, &registry, base_path.clone()).unwrap_or_else(|error| {
518                tracing::error!(%error, "failed to build the admin manifest — the dashboard will not load");
519                "{}".to_string()
520            })
521        }
522        None => String::new(),
523    };
524    if let Some(dir) = &statics.public_dir {
525        tracing::info!(
526            routes = statics.public_routes.len(),
527            "  public -> /  (from {})",
528            dir.display()
529        );
530    }
531    if let Some(page) = &statics.not_found_page {
532        tracing::info!("  404 -> {}", page.display());
533    }
534
535    let state = AppState {
536        app: Arc::new(app),
537        db,
538        auth: authr,
539        functions: Arc::new(registry),
540        mailer,
541        cache,
542        statics: Arc::new(statics),
543        admin_manifest: Arc::new(admin_manifest),
544        openapi_json: Arc::new(openapi_json),
545        docs_html: Arc::new(docs_html),
546    };
547
548    let base_path_log = base_path.clone();
549    let mut server = HttpServer::new(move || build_app!(state));
550
551    if let Some(w) = workers {
552        server = server.workers(w);
553    }
554
555    let addr = format!("{host}:{port}");
556    let scheme = if tls.is_some() { "https" } else { "http" };
557    let server = match tls {
558        Some(paths) => server.bind_rustls(&addr, load_tls(&paths)?)?,
559        None => server.bind(&addr)?,
560    };
561
562    tracing::info!("apiplant listening on {scheme}://{addr}{base_path_log}");
563    server.run().await?;
564    Ok(())
565}
566
567/// Build a rustls server config from PEM cert + key files.
568fn load_tls(paths: &TlsPaths) -> anyhow::Result<rustls::ServerConfig> {
569    use std::io::BufReader;
570
571    // Install a default crypto provider once (ring); ignore "already set".
572    let _ = rustls::crypto::ring::default_provider().install_default();
573
574    let mut cert_reader = BufReader::new(std::fs::File::open(&paths.cert)?);
575    let certs = rustls_pemfile::certs(&mut cert_reader).collect::<Result<Vec<_>, _>>()?;
576
577    let mut key_reader = BufReader::new(std::fs::File::open(&paths.key)?);
578    let key = rustls_pemfile::private_key(&mut key_reader)?
579        .ok_or_else(|| anyhow::anyhow!("no private key in {}", paths.key.display()))?;
580
581    let config = rustls::ServerConfig::builder()
582        .with_no_client_auth()
583        .with_single_cert(certs, key)?;
584    Ok(config)
585}
586
587#[cfg(test)]
588mod route_tests {
589    use super::*;
590
591    #[test]
592    fn an_index_answers_for_its_directory_too() {
593        assert_eq!(public_routes("index.html"), ["/index.html", "/"]);
594        assert_eq!(
595            public_routes("guide/index.html"),
596            ["/guide/index.html", "/guide/", "/guide"]
597        );
598        assert_eq!(public_routes("css/app.css"), ["/css/app.css"]);
599    }
600
601    #[test]
602    fn names_that_cannot_be_routes_are_skipped() {
603        assert!(public_routes("weird{name}.html").is_empty());
604        assert!(public_routes("../escape.html").is_empty());
605    }
606
607    #[test]
608    fn static_paths_resolve_under_the_root_and_never_above_it() {
609        let root = Path::new("/srv/app/public");
610        assert_eq!(
611            resolve_static_path(root, "/css/app.css"),
612            Some(root.join("css/app.css"))
613        );
614        assert_eq!(
615            resolve_static_path(root, "/"),
616            Some(root.join("index.html"))
617        );
618        assert_eq!(resolve_static_path(root, "/../main.toml"), None);
619    }
620}