mod accept;
mod admin;
mod budget;
mod bundling;
mod config;
mod health;
mod json_fmt;
mod listen;
mod listener;
mod middleware;
mod reconfig;
mod router;
mod svg;
mod themes;
mod types;
mod util;
mod vega;
mod vegalite;
#[cfg(test)]
mod test_support;
pub use admin::admin_openapi;
pub use config::{init_tracing, BuiltApp, LogFormat, ServeConfig};
pub(crate) use config::{validate_serve_config, AdminConfig, ApiKey, AppState, RuntimeSnapshot};
pub use listen::ListenAddr;
#[cfg(unix)]
pub use listener::UdsCleanup;
pub use listener::{bind_listener, BoundListener, EndpointInfo};
pub use router::public_openapi;
pub(crate) use router::{build_middleware_stack, build_router};
use std::future::Future;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use tower_http::catch_panic::CatchPanicLayer;
use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
use tower_http::trace::TraceLayer;
use vl_convert_rs::anyhow;
use vl_convert_rs::converter::VlcConfig;
pub async fn serve(
listener: BoundListener,
built: BuiltApp,
shutdown: impl Future<Output = ()> + Send + 'static,
) -> Result<(), anyhow::Error> {
let token = built.shutdown_token.clone();
let mut tasks: JoinSet<()> = JoinSet::new();
if let Some(tracker) = built.tracker {
let cancel = token.clone();
tasks.spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(1));
loop {
tokio::select! {
_ = interval.tick() => tracker.refill(),
_ = cancel.cancelled() => break,
}
}
});
}
if let Some(admin) = built.admin {
let cancel = token.clone();
tasks.spawn(async move {
log::info!("Admin API listening on {}", admin.addr);
let admin_shutdown = async move { cancel.cancelled().await };
match admin.listener {
BoundListener::Tcp(l) => {
let _ = axum::serve(l, admin.router)
.with_graceful_shutdown(admin_shutdown)
.await;
}
#[cfg(unix)]
BoundListener::Uds(l, _cleanup) => {
let _ = listener::serve_uds(l, admin.router, admin_shutdown).await;
}
}
});
}
let shutdown_token = token;
let shutdown = async move {
shutdown.await;
shutdown_token.cancel();
};
match listener {
BoundListener::Tcp(l) => {
axum::serve(
l,
built
.router
.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown)
.await?;
}
#[cfg(unix)]
BoundListener::Uds(l, _cleanup) => {
listener::serve_uds(l, built.router, shutdown).await?;
}
}
while tasks.join_next().await.is_some() {}
Ok(())
}
pub async fn build_app(
config: VlcConfig,
serve_config: &ServeConfig,
) -> Result<BuiltApp, anyhow::Error> {
use arc_swap::ArcSwap;
validate_serve_config(serve_config)?;
let shutdown_token = CancellationToken::new();
let reconfig_drain_timeout = Duration::from_secs(serve_config.reconfig_drain_timeout_secs);
let coordinator =
reconfig::ReconfigCoordinator::new(shutdown_token.clone(), reconfig_drain_timeout);
let num_workers = config.num_workers;
log::info!("Initializing converter with {num_workers} worker(s)...");
let converter = vl_convert_rs::converter::VlConverter::with_config(config)?;
converter.warm_up()?;
let local_tz = match converter.get_local_tz().await {
Ok(tz) => tz,
Err(err) => {
log::warn!("Failed to determine local timezone for /infoz: {err}");
None
}
};
log::info!("Workers initialized");
let normalized = converter.config();
let baseline = Arc::new(normalized.clone());
let runtime = Arc::new(ArcSwap::from_pointee(RuntimeSnapshot {
converter,
config: Arc::new(normalized),
generation: 0,
}));
let api_key = serve_config
.api_key
.as_ref()
.map(|k| ApiKey::new(k.clone()));
let readiness = Arc::new(health::ReadinessState::default());
let state = Arc::new(AppState {
runtime: runtime.clone(),
api_key,
opaque_errors: serve_config.opaque_errors,
require_user_agent: serve_config.require_user_agent,
readiness: readiness.clone(),
local_tz,
coordinator: coordinator.clone(),
});
let tracker = if serve_config.per_ip_budget_ms.is_some()
|| serve_config.global_budget_ms.is_some()
|| serve_config.admin.is_some()
{
Some(budget::BudgetTracker::new(
serve_config.per_ip_budget_ms.unwrap_or(0),
serve_config.global_budget_ms.unwrap_or(0),
serve_config.budget_hold_ms,
))
} else {
None
};
let admin = if let (Some(admin_addr), Some(t)) = (&serve_config.admin, &tracker) {
let bound: BoundListener = bind_listener(admin_addr, serve_config.socket_mode).await?;
let addr = bound.endpoint_label();
let admin_state = Arc::new(admin::AdminState {
runtime: runtime.clone(),
baseline: baseline.clone(),
coordinator: coordinator.clone(),
readiness: readiness.clone(),
admin_api_key: serve_config
.admin_api_key
.as_ref()
.map(|k| ApiKey::new(k.clone())),
tracker: t.clone(),
opaque_errors: serve_config.opaque_errors,
});
let admin_router = admin::admin_router(admin_state)
.layer(PropagateRequestIdLayer::x_request_id())
.layer(TraceLayer::new_for_http())
.layer(SetRequestIdLayer::x_request_id(MakeRequestUuid))
.layer(CatchPanicLayer::new());
Some(AdminConfig {
listener: bound,
addr,
router: admin_router,
})
} else {
None
};
let router = build_router(
state,
tracker.clone(),
serve_config.opaque_errors,
serve_config.trust_proxy,
serve_config.google_font_cache_miss_penalty_ms,
);
let app = build_middleware_stack(router, serve_config);
Ok(BuiltApp {
router: app,
runtime,
shutdown_token,
tracker,
admin,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::default_serve_config;
#[tokio::test]
async fn test_build_app_rejects_non_positive_budget_hold_ms() {
let config = VlcConfig::default();
let mut serve_config = default_serve_config();
serve_config.budget_hold_ms = 0;
let err = build_app(config, &serve_config).await.err().unwrap();
assert!(
err.to_string().contains("budget_hold_ms must be positive"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn test_build_app_rejects_empty_main_api_key() {
for key in ["", " ", "\t"] {
let mut serve_config = default_serve_config();
serve_config.api_key = Some(key.to_string());
let err = build_app(VlcConfig::default(), &serve_config)
.await
.err()
.unwrap();
assert!(
err.to_string().contains("api_key"),
"key {key:?} must trigger validator: {err}"
);
}
}
#[tokio::test]
async fn test_build_app_rejects_empty_admin_api_key_on_safe_admin_bind() {
for key in ["", " ", "\t"] {
let mut serve_config = default_serve_config();
serve_config.admin = Some(crate::ListenAddr::loopback_tcp(0));
serve_config.admin_api_key = Some(key.to_string());
let err = build_app(VlcConfig::default(), &serve_config)
.await
.err()
.unwrap();
assert!(
err.to_string().contains("admin_api_key"),
"loopback admin key {key:?} must trigger validator: {err}"
);
}
#[cfg(unix)]
for key in ["", " ", "\t"] {
let mut serve_config = default_serve_config();
serve_config.admin = Some(crate::ListenAddr::Uds {
path: "/tmp/vl-convert-admin-test.sock".into(),
});
serve_config.admin_api_key = Some(key.to_string());
let err = build_app(VlcConfig::default(), &serve_config)
.await
.err()
.unwrap();
assert!(
err.to_string().contains("admin_api_key"),
"UDS admin key {key:?} must trigger validator: {err}"
);
}
}
#[tokio::test]
async fn test_build_app_rejects_non_loopback_admin_without_usable_key() {
for key in [None, Some(""), Some(" "), Some("\t")] {
let mut serve_config = default_serve_config();
serve_config.admin = Some(crate::ListenAddr::Tcp {
host: "0.0.0.0".to_string(),
port: 0,
});
serve_config.admin_api_key = key.map(String::from);
let err = build_app(VlcConfig::default(), &serve_config)
.await
.err()
.unwrap();
assert!(
err.to_string().contains("admin_api_key"),
"key {key:?} must trigger validator: {err}"
);
}
}
#[tokio::test]
async fn test_build_app_accepts_non_loopback_admin_with_key() {
let mut serve_config = default_serve_config();
serve_config.admin = Some(crate::ListenAddr::Tcp {
host: "0.0.0.0".to_string(),
port: 0,
});
serve_config.admin_api_key = Some("supersecret".to_string());
serve_config.global_budget_ms = Some(1_000);
if let Err(err) = build_app(VlcConfig::default(), &serve_config).await {
assert!(
!err.to_string().contains("admin_api_key"),
"validator rejected admin with key: {err}"
);
}
}
}