macro_rules! build_app {
($state:expr) => {{
let state = $state.clone();
let config = &state.app.config;
let domain = config.server.domain.clone();
let statics = state.statics.clone();
let base_path = match config.server.base_path.as_str() {
"" => "/",
path => path,
};
let mut scope = $crate::ntex_web::scope(base_path);
if let Some(g) = $crate::host_guard(&domain) {
scope = scope.guard(g);
}
if config.docs.enabled {
scope = scope
.route(
"/openapi.json",
$crate::ntex_web::get().to($crate::openapi_spec),
)
.route(
config.docs.path.as_str(),
$crate::ntex_web::get().to($crate::docs_page),
);
}
let mut scope = scope
.route("/_health", $crate::ntex_web::get().to($crate::health))
.route(
"/auth/register",
$crate::ntex_web::post().to($crate::auth_routes::register),
)
.route(
"/auth/login",
$crate::ntex_web::post().to($crate::auth_routes::login),
)
.route(
"/auth/me",
$crate::ntex_web::get().to($crate::auth_routes::me),
)
.route(
"/auth/apikeys",
$crate::ntex_web::post().to($crate::auth_routes::create_api_key),
);
if let Some(storage) = &state.storage {
scope = scope.service(
$crate::ntex_web::resource("/uploads")
.state($crate::ntex_web::types::PayloadConfig::new(
storage.max_bytes() as usize,
))
.route($crate::ntex_web::post().to($crate::storage_routes::upload)),
);
}
scope = scope.route(
"/queues/{topic}",
$crate::ntex_web::post().to($crate::queue_routes::publish),
);
if state.invitations_enabled() {
scope = scope
.route(
"/auth/invitations",
$crate::ntex_web::post().to($crate::email_auth::create_invitation),
)
.route(
"/auth/invitations/{token}",
$crate::ntex_web::get().to($crate::email_auth::preview_invitation),
)
.route(
"/auth/invitations/{token}/accept",
$crate::ntex_web::post().to($crate::email_auth::accept_invitation),
);
}
if state.requires_email_verification() {
scope = scope
.route(
"/auth/verify-email",
$crate::ntex_web::post().to($crate::email_auth::verify_email),
)
.route(
"/auth/verify-email/resend",
$crate::ntex_web::post().to($crate::email_auth::resend_verification),
);
}
if state.password_reset_enabled() {
scope = scope
.route(
"/auth/password/forgot",
$crate::ntex_web::post().to($crate::email_auth::forgot_password),
)
.route(
"/auth/password/reset",
$crate::ntex_web::post().to($crate::email_auth::reset_password),
);
}
if state.oauth_enabled() {
scope = scope
.route(
"/auth/oauth",
$crate::ntex_web::get().to($crate::oauth_routes::providers),
)
.route(
"/auth/oauth/{provider}/start",
$crate::ntex_web::get().to($crate::oauth_routes::start_redirect),
)
.route(
"/auth/oauth/{provider}/start",
$crate::ntex_web::post().to($crate::oauth_routes::start_json),
)
.route(
"/auth/oauth/{provider}/callback",
$crate::ntex_web::get().to($crate::oauth_routes::callback_redirect),
)
.route(
"/auth/oauth/{provider}/callback",
$crate::ntex_web::post().to($crate::oauth_routes::callback_json),
)
.route(
"/auth/oauth/{provider}",
$crate::ntex_web::delete().to($crate::oauth_routes::unlink),
);
}
if state.payments_enabled() {
scope = scope
.route(
"/billing/config",
$crate::ntex_web::get().to($crate::billing::config),
)
.route(
"/billing/checkout",
$crate::ntex_web::post().to($crate::billing::checkout),
)
.route(
"/billing/portal",
$crate::ntex_web::post().to($crate::billing::portal),
)
.route(
"/billing/webhook",
$crate::ntex_web::post().to($crate::billing::webhook),
);
}
if state.ai_enabled() {
scope = scope
.route(
"/ai/config",
$crate::ntex_web::get().to($crate::ai_routes::config),
)
.route(
"/ai/agents/{name}/chat",
$crate::ntex_web::post().to($crate::agent_routes::chat),
)
.route(
"/ai/chat",
$crate::ntex_web::post().to($crate::ai_routes::chat),
);
}
let mut scope = scope
.route(
"/functions/{name}/stream",
$crate::ntex_web::route().to($crate::function_routes::stream),
)
.route(
"/functions/{name}",
$crate::ntex_web::route().to($crate::function_routes::invoke),
)
.service(
$crate::ntex_web::resource("/{resource}")
.route($crate::ntex_web::get().to($crate::crud::list))
.route($crate::ntex_web::post().to($crate::crud::create)),
)
.service(
$crate::ntex_web::resource("/{resource}/{id}")
.route($crate::ntex_web::get().to($crate::crud::get))
.route($crate::ntex_web::patch().to($crate::crud::update))
.route($crate::ntex_web::put().to($crate::crud::update))
.route($crate::ntex_web::delete().to($crate::crud::delete)),
)
.route(
"/{parent}/{id}/{child}",
$crate::ntex_web::get().to($crate::crud::nested_list),
);
if statics.not_found_page.is_some() {
scope = scope.default_service($crate::ntex_web::to($crate::not_found_route));
}
let scope = scope.wrap($crate::rate_limit::RateLimit::new(::std::sync::Arc::clone(
&state.rate_limit,
)));
let scope = scope.wrap($crate::telemetry::Telemetry::new(::std::sync::Arc::clone(
&state.telemetry,
)));
let mut app = $crate::ntex_web::App::new().state(state.clone());
macro_rules! guarded {
($path:expr) => {{
let resource = $crate::ntex_web::resource($path);
match $crate::host_guard(&domain) {
Some(g) => resource.guard(g),
None => resource,
}
}};
}
if let Some(admin_path) = &statics.admin_path {
app = app
.service(
guarded!(format!("{admin_path}/"))
.route($crate::ntex_web::get().to($crate::admin_index)),
)
.service(
guarded!(format!("{admin_path}/{{path:.*}}"))
.route($crate::ntex_web::get().to($crate::admin_asset)),
)
.service(
guarded!(admin_path.as_str())
.route($crate::ntex_web::get().to($crate::admin_redirect)),
);
}
if let Some(base) = &statics.storage_base {
app = app.service(
guarded!(format!("{base}/{{key}}*"))
.route($crate::ntex_web::get().to($crate::storage_routes::serve)),
);
}
for route in &statics.public_routes {
app = app.service(
guarded!(route.as_str()).route($crate::ntex_web::get().to($crate::public_asset)),
);
}
app = app.service(scope);
if statics.not_found_page.is_some() {
app = app.default_service($crate::ntex_web::to($crate::not_found_route));
}
app
}};
}
pub(crate) fn host_guard(domains: &[String]) -> Option<ntex_guard::AnyGuard> {
if domains.is_empty() {
return None;
}
Some(ntex_guard::AnyGuard(
domains
.iter()
.map(|d| Box::new(ntex_guard::Host(d.clone())) as Box<dyn ntex_guard::Guard>)
.collect(),
))
}
pub mod access;
pub mod admin;
mod agent_routes;
mod ai_routes;
mod auth_routes;
mod banner;
mod billing;
pub mod builtins;
pub mod cabi;
pub mod call;
mod crud;
pub mod email_auth;
mod emails;
mod function_routes;
pub mod functions;
pub mod hooks;
mod oauth_routes;
mod openapi;
mod queue_routes;
pub mod queues;
pub mod rate_limit;
mod response;
mod sse;
mod state;
mod storage_routes;
pub mod telemetry;
#[cfg(test)]
mod tests;
use std::sync::Arc;
use std::{fs, path::Component, path::Path, path::PathBuf};
use apiplant_auth::Authenticator;
use apiplant_core::{App, TlsPaths};
use apiplant_db::Db;
use ntex::web::{self, HttpRequest, HttpResponse, HttpServer};
pub(crate) use ntex::web as ntex_web;
pub(crate) use ntex::web::guard as ntex_guard;
use uuid::Uuid;
use functions::FunctionRegistry;
use state::{AppState, Statics};
async fn health() -> HttpResponse {
HttpResponse::Ok().json(&serde_json::json!({ "status": "ok", "framework": "apiplant" }))
}
async fn openapi_spec(state: web::types::State<AppState>) -> HttpResponse {
HttpResponse::Ok()
.content_type("application/json")
.body(state.openapi_json.as_str().to_owned())
}
async fn docs_page(state: web::types::State<AppState>) -> HttpResponse {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(state.docs_html.as_str().to_owned())
}
async fn admin_index(state: web::types::State<AppState>) -> HttpResponse {
serve_admin(&state, "index.html")
}
async fn admin_asset(
state: web::types::State<AppState>,
path: web::types::Path<String>,
) -> HttpResponse {
let path = path.into_inner();
serve_admin(&state, &path)
}
fn serve_admin(state: &AppState, requested: &str) -> HttpResponse {
let requested = requested.trim_start_matches('/');
if requested == admin::MANIFEST_FILE {
return HttpResponse::Ok()
.content_type("application/json")
.body(state.admin_manifest.as_str().to_owned());
}
match admin::asset(requested) {
Some(bytes) => HttpResponse::Ok()
.content_type(apiplant_assets::content_type(requested))
.body(bytes.into_owned()),
None => HttpResponse::NotFound().finish(),
}
}
async fn public_asset(state: web::types::State<AppState>, req: HttpRequest) -> HttpResponse {
let Some(root) = state.statics.public_dir.as_deref() else {
return not_found(&state);
};
serve_file(root, req.path()).unwrap_or_else(|| not_found(&state))
}
async fn not_found_route(state: web::types::State<AppState>) -> HttpResponse {
not_found(&state)
}
fn not_found(state: &AppState) -> HttpResponse {
let Some(page) = state.statics.not_found_page.as_deref() else {
return HttpResponse::NotFound().finish();
};
match fs::read(page) {
Ok(bytes) => HttpResponse::NotFound()
.content_type(content_type_for(page))
.body(bytes),
Err(error) => {
tracing::error!(path = %page.display(), error = %error, "failed to read 404 page");
HttpResponse::NotFound().finish()
}
}
}
fn serve_file(root: &Path, requested: &str) -> Option<HttpResponse> {
let path = resolve_static_path(root, requested)?;
match fs::read(&path) {
Ok(bytes) => Some(
HttpResponse::Ok()
.content_type(content_type_for(&path))
.body(bytes),
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => {
tracing::error!(path = %path.display(), error = %error, "failed to read static file");
Some(HttpResponse::InternalServerError().finish())
}
}
}
fn resolve_static_path(root: &Path, requested: &str) -> Option<PathBuf> {
let mut path = root.to_path_buf();
let requested = requested.trim_matches('/');
if requested.is_empty() {
path.push("index.html");
return Some(path);
}
for component in Path::new(requested).components() {
match component {
Component::Normal(segment) => path.push(segment),
Component::CurDir => {}
_ => return None,
}
}
if path.is_dir() {
path.push("index.html");
}
Some(path)
}
fn content_type_for(path: &Path) -> &'static str {
apiplant_assets::content_type(&path.to_string_lossy())
}
async fn admin_redirect(req: HttpRequest) -> HttpResponse {
HttpResponse::PermanentRedirect()
.header("location", format!("{}/", req.path()))
.finish()
}
fn public_routes(relative: &str) -> Vec<String> {
if relative
.split('/')
.any(|segment| segment.is_empty() || segment.contains(['{', '}']) || segment == "..")
{
tracing::warn!(
file = relative,
"skipping public file: its name can't be a route"
);
return Vec::new();
}
let mut routes = vec![format!("/{relative}")];
if let Some(directory) = relative.strip_suffix("index.html") {
let directory = directory.trim_end_matches('/');
if directory.is_empty() {
routes.push("/".to_string());
} else {
routes.push(format!("/{directory}/"));
routes.push(format!("/{directory}"));
}
}
routes
}
fn walk_public(root: &Path, prefix: &str, into: &mut Vec<String>) {
let entries = match fs::read_dir(root) {
Ok(entries) => entries,
Err(error) => {
tracing::error!(path = %root.display(), error = %error, "failed to read public directory");
return;
}
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let relative = if prefix.is_empty() {
name
} else {
format!("{prefix}/{name}")
};
if entry.path().is_dir() {
walk_public(&entry.path(), &relative, into);
} else {
into.push(relative);
}
}
}
pub async fn run(app: App) -> anyhow::Result<()> {
run_with(app, Options::default()).await
}
#[derive(Debug, Clone, Default)]
pub struct Options {
pub seed: bool,
}
pub async fn run_with(app: App, options: Options) -> anyhow::Result<()> {
let db_url = app.config.database.resolved_url();
tracing::info!("connecting to database");
let db = Db::connect(&db_url, app.config.database.max_connections).await?;
if app.config.database.auto_migrate {
tracing::info!("running migrations");
apiplant_db::migrate(db.connection(), &app).await?;
}
if options.seed {
let report = apiplant_db::seed::seed(db.connection(), &app).await?;
if report.is_empty() {
tracing::warn!("--seed was given but there is no seed/ directory to load");
} else {
tracing::info!(
inserted = report.inserted(),
already_present = report.skipped(),
"seeded"
);
}
}
let secret = if app.config.auth.jwt_secret.is_empty() {
tracing::warn!(
"auth.jwt_secret is empty — using an ephemeral secret; sessions won't survive a restart"
);
format!("{}{}", Uuid::new_v4(), Uuid::new_v4()).into_bytes()
} else {
app.config.auth.jwt_secret.clone().into_bytes()
};
let authr = Authenticator::new(secret, app.config.auth.session_ttl_secs);
let mailer = apiplant_email::Mailer::from_config(&app.config.email)?;
match &mailer {
Some(mailer) => tracing::info!(
" email -> {} (from {})",
mailer.provider().as_str(),
app.config.email.from
),
None => tracing::debug!("no email provider configured"),
}
let cache = apiplant_cache::Cache::connect(&app.config.cache).await?;
match &cache {
Some(_) => tracing::info!(
" cache -> redis (prefix {:?})",
app.config.cache.prefix.as_str()
),
None => tracing::debug!("no cache configured"),
}
let storage = apiplant_storage::Storage::connect(&app.config.storage, &app.root)
.map_err(|e| apiplant_core::Error::Message(e.to_string()))?;
match &storage {
Some(storage) => tracing::info!(
" storage -> {} ({}), served at {}/",
storage.kind(),
storage.location(),
storage.public_base()
),
None => tracing::debug!("no storage configured"),
}
let queue = apiplant_queue::Queue::new(&db, &app);
if app.config.queues.is_active() {
for (topic, subscribers) in &app.config.queues.subscribe {
tracing::info!(" topic {topic} -> {}", subscribers.join(", "));
}
}
let ai = apiplant_ai::Ai::from_config(&app.config.ai)?;
let agent_ais = app
.agents
.values()
.filter_map(|agent| {
agent.ai.as_ref().map(|_| {
apiplant_ai::Ai::from_config(&agent.merged_ai_config(&app.config.ai))
.map(|ai| (agent.meta.name.clone(), ai))
})
})
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.filter_map(|(name, ai)| ai.map(|ai| (name, ai)))
.collect();
match &ai {
Some(ai) => tracing::info!(
" ai -> {} ({} at {})",
ai.provider().as_str(),
match ai.model() {
"" => "the server's own model",
model => model,
},
ai.url()
),
None => tracing::debug!("no ai provider configured"),
}
let billing_landing = match app.config.admin.enabled {
true => format!(
"{}{}/#/billing",
app.config.server.public_origin(),
app.config.admin.path.trim_end_matches('/')
),
false => app.config.server.public_origin(),
};
let payments =
apiplant_payments::Payments::from_config(&app.config.payments, &billing_landing)?;
match &payments {
Some(payments) => tracing::info!(
" payments -> {} ({}, automatic tax {})",
payments.provider().as_str(),
app.config.payments.default_currency(),
match app.config.payments.automatic_tax {
true => "on",
false => "off",
}
),
None => tracing::debug!("no payment provider configured"),
}
oauth_routes::check_resources(&app).map_err(apiplant_core::Error::Message)?;
let callback_base = format!(
"{}{}/auth/oauth",
app.config.server.public_origin(),
app.config.server.base_path.trim_end_matches('/'),
);
let oauth = apiplant_oauth::Providers::from_config(&app.config.oauth, &callback_base)
.map_err(|e| apiplant_core::Error::Message(e.to_string()))?;
match &oauth {
Some(providers) => {
for provider in providers.iter() {
tracing::info!(
" oauth {} -> {}/auth/oauth/{}/start (redirect URI: {})",
provider.label,
app.config.server.base_path,
provider.key,
provider.redirect_uri,
);
}
}
None => tracing::debug!("no oauth providers configured"),
}
let registry = FunctionRegistry::load(&app);
for f in registry.iter() {
if f.manifest.visibility == apiplant_abi::Visibility::Private {
tracing::info!(" fn {} (private — no endpoint)", f.manifest.name);
} else {
tracing::info!(
" fn {} -> {}/functions/{}",
f.manifest.name,
app.config.server.base_path,
f.manifest.name
);
}
}
for name in app.config.queues.subscribed_functions() {
if registry.get(name).is_none() {
tracing::error!(
function = name,
"a [queues.subscribe] entry names a function that is not loaded — \
messages on its topic will retry and then fail"
);
}
}
for resource in app.resources.values() {
for (event, function) in resource.hooks.iter() {
if registry.get(function).is_some() {
tracing::info!(
" hook {}.{} -> {}",
resource.meta.name,
event.as_str(),
function
);
} else {
tracing::error!(
resource = %resource.meta.name,
hook = event.as_str(),
function = function,
"hook function is not loaded — this resource's {} requests will fail with 500",
event.action()
);
}
}
for (event, function) in resource.hooks.auth_iter() {
if registry.get(function).is_some() {
tracing::info!(" hook auth.{} -> {}", event.as_str(), function);
} else {
tracing::error!(
hook = event.as_str(),
function = function,
"auth hook function is not loaded — {} requests will fail with 500",
event.action()
);
}
}
}
let base_path = app.config.server.base_path.clone();
let spec_url = format!("{base_path}/openapi.json");
let spec = openapi::build(&app, ®istry, mailer.is_some());
let openapi_json = serde_json::to_string(&spec).unwrap_or_else(|_| "{}".to_string());
let docs_html = openapi::swagger_ui_html(&spec_url, &app.docs_title());
if app.config.docs.enabled {
tracing::info!(
" docs -> {base_path}{} (spec: {spec_url})",
app.config.docs.path
);
}
let host = app.config.server.host.clone();
let port = app.config.server.port;
let banner_docs_path = app
.config
.docs
.enabled
.then(|| app.config.docs.path.clone());
let banner_domains = app.config.server.domain.clone();
let banner_name = app.display_name();
let workers = app.config.server.workers;
let tls = app.tls.clone();
let statics = Statics::resolve(&app);
let banner_admin_path = statics.admin_path.clone();
let banner_site = !statics.public_routes.is_empty();
let admin_manifest = match &statics.admin_path {
Some(path) => {
tracing::info!(" admin -> {path}/");
admin::manifest_json(&app, ®istry, base_path.clone(), mailer.is_some()).unwrap_or_else(|error| {
tracing::error!(%error, "failed to build the admin manifest — the dashboard will not load");
"{}".to_string()
})
}
None => String::new(),
};
if let Some(dir) = &statics.public_dir {
tracing::info!(
routes = statics.public_routes.len(),
" public -> / (from {})",
dir.display()
);
}
if let Some(page) = &statics.not_found_page {
tracing::info!(" 404 -> {}", page.display());
}
let rate_limit = rate_limit::RateLimitPolicy::build(&app, ®istry);
if rate_limit.is_active() {
tracing::info!(
overrides = rate_limit.overrides(),
" rate limit -> {}",
app.config.rate_limit.default.as_string()
);
}
let telemetry =
telemetry::TelemetryPolicy::build(&app.config.observability, &app.config.server.base_path);
if telemetry.is_active() {
tracing::info!(
traces = app.config.observability.traces.enabled,
metrics = app.config.observability.metrics.enabled,
" observability -> {}",
app.config
.observability
.endpoint()
.unwrap_or_else(|| "in-process".to_string())
);
}
let state = AppState {
app: Arc::new(app),
db,
auth: authr,
functions: Arc::new(registry),
mailer,
cache,
storage,
payments,
ai,
oauth: oauth.map(Arc::new),
queue: queue.clone(),
agent_ais: Arc::new(agent_ais),
rate_limit: Arc::new(rate_limit),
telemetry: Arc::new(telemetry),
statics: Arc::new(statics),
admin_manifest: Arc::new(admin_manifest),
openapi_json: Arc::new(openapi_json),
docs_html: Arc::new(docs_html),
};
if state.app.config.queues.is_active() {
let subscriber = queues::Subscriber {
db: state.db.clone(),
queue: queue.clone(),
functions: Arc::clone(&state.functions),
mailer: state.mailer.clone(),
cache: state.cache.clone(),
payments: state.payments.clone(),
ai: state.ai.clone(),
database_url: db_url.clone(),
worker: format!("{}:{}", hostname(), std::process::id()),
};
tokio::spawn(queues::run(subscriber));
}
let base_path_log = base_path.clone();
let mut server = HttpServer::new(move || build_app!(state));
if let Some(w) = workers {
server = server.workers(w);
}
let addr = format!("{host}:{port}");
let scheme = if tls.is_some() { "https" } else { "http" };
let server = match tls {
Some(paths) => server.bind_rustls(&addr, load_tls(&paths)?)?,
None => server.bind(&addr)?,
};
tracing::info!("apiplant listening on {scheme}://{addr}{base_path_log}");
banner::Banner {
name: banner_name,
scheme,
addr: addr.clone(),
base_path: base_path_log.clone(),
docs_path: banner_docs_path,
admin_path: banner_admin_path,
site: banner_site,
domains: banner_domains,
}
.print();
server.run().await?;
Ok(())
}
fn hostname() -> String {
std::env::var("HOSTNAME")
.ok()
.filter(|h| !h.trim().is_empty())
.unwrap_or_else(|| "unknown".to_string())
}
fn load_tls(paths: &TlsPaths) -> anyhow::Result<rustls::ServerConfig> {
use std::io::BufReader;
let _ = rustls::crypto::ring::default_provider().install_default();
let mut cert_reader = BufReader::new(std::fs::File::open(&paths.cert)?);
let certs = rustls_pemfile::certs(&mut cert_reader).collect::<Result<Vec<_>, _>>()?;
let mut key_reader = BufReader::new(std::fs::File::open(&paths.key)?);
let key = rustls_pemfile::private_key(&mut key_reader)?
.ok_or_else(|| anyhow::anyhow!("no private key in {}", paths.key.display()))?;
let config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)?;
Ok(config)
}
#[cfg(test)]
mod route_tests {
use super::*;
#[test]
fn an_index_answers_for_its_directory_too() {
assert_eq!(public_routes("index.html"), ["/index.html", "/"]);
assert_eq!(
public_routes("guide/index.html"),
["/guide/index.html", "/guide/", "/guide"]
);
assert_eq!(public_routes("css/app.css"), ["/css/app.css"]);
}
#[test]
fn names_that_cannot_be_routes_are_skipped() {
assert!(public_routes("weird{name}.html").is_empty());
assert!(public_routes("../escape.html").is_empty());
}
#[test]
fn static_paths_resolve_under_the_root_and_never_above_it() {
let root = Path::new("/srv/app/public");
assert_eq!(
resolve_static_path(root, "/css/app.css"),
Some(root.join("css/app.css"))
);
assert_eq!(
resolve_static_path(root, "/"),
Some(root.join("index.html"))
);
assert_eq!(resolve_static_path(root, "/../main.toml"), None);
}
}