use std::path::Path;
use std::sync::Arc;
use actix_web::{HttpResponse, web};
use noema::config::ConfigSource;
use noema::resolve;
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::fmt;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, Layer};
use crate::clock::Clock;
use crate::config::{ApplicationConfig, DatabaseConfig, Environment, LogConfig};
use crate::db;
use crate::hasher::Hasher;
use crate::http_client::HttpClient;
use crate::logger::Logger;
use crate::modules::Modules;
use crate::ws::SessionHub;
#[derive(Debug)]
pub enum Error {
Config(noema::ConfigError),
Database(sqlx::Error),
Io(std::io::Error),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Config(e) => write!(f, "{e}"),
Self::Database(e) => write!(f, "{e}"),
Self::Io(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for Error {}
impl From<noema::ConfigError> for Error {
fn from(value: noema::ConfigError) -> Self {
Self::Config(value)
}
}
impl From<sqlx::Error> for Error {
fn from(value: sqlx::Error) -> Self {
Self::Database(value)
}
}
impl From<std::io::Error> for Error {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
#[derive(Clone)]
pub struct Application {
modules: Arc<Modules>,
bind: String,
health: bool,
ready: bool,
_log_guard: Option<Arc<WorkerGuard>>,
}
impl Application {
pub async fn start(
modules: Modules,
source: impl ConfigSource + 'static,
config: ApplicationConfig,
) -> Result<Self, Error> {
let source: std::sync::Arc<dyn ConfigSource> = std::sync::Arc::new(source);
source.load().await?;
let url = source
.get("DATABASE_URL")
.await?
.filter(|s| !s.is_empty())
.ok_or_else(|| noema::ConfigError::MissingKey("DATABASE_URL".into()))?;
match noema::load(source).await {
Ok(()) => {}
Err(noema::ConfigError::AlreadyLoaded(_)) => {}
Err(err) => return Err(err.into()),
}
crate::config::install(config.clone());
crate::config::install_environment(config.http.env);
crate::http_client::install(config.http_client.clone());
crate::cors::install(config.cors.clone());
let bind = config.http.bind_addr();
db::start(&DatabaseConfig {
url,
max_connections: config.database.max_connections,
acquire_timeout_ms: config.database.acquire_timeout_ms,
})
.await?;
let pool = resolve::<crate::PgPool>();
modules.run_migrators(pool.connection()).await?;
crate::ws::install_origin();
modules.configure_infrastructure().await;
let log_guard = init_logging(&config.log)?;
let _ = resolve::<ApplicationConfig>();
let _ = resolve::<SessionHub>();
let _ = resolve::<dyn Logger + Send + Sync>();
let _ = resolve::<dyn Hasher + Send + Sync>();
let _ = resolve::<dyn Clock + Send + Sync>();
let _ = resolve::<dyn HttpClient + Send + Sync>();
Ok(Self {
modules: Arc::new(modules),
bind,
health: false,
ready: false,
_log_guard: log_guard.map(Arc::new),
})
}
pub fn health(mut self) -> Self {
self.health = true;
self
}
pub fn ready(mut self) -> Self {
self.ready = true;
self
}
pub fn bind_addr(&self) -> &str {
&self.bind
}
pub fn spawn_background_tasks(
&self,
tasks: impl IntoIterator<Item = std::sync::Arc<dyn crate::BackgroundTask + Send + Sync>>,
) {
crate::spawn_background_tasks(tasks);
}
pub fn on_ws_publish(&self, publish: impl Fn(String, String) + Send + Sync + 'static) {
crate::ws::on_ws_publish(publish);
}
pub fn configure(&self, cfg: &mut web::ServiceConfig) {
if self.health {
cfg.route("/health", web::get().to(health));
}
if self.ready {
cfg.route("/ready", web::get().to(ready));
}
self.modules.configure_presentation(cfg);
self.modules.mount_swagger(cfg, Arc::clone(&self.modules));
}
}
pub async fn start(
modules: Modules,
source: impl ConfigSource + 'static,
config: ApplicationConfig,
) -> Result<Application, Error> {
Application::start(modules, source, config).await
}
fn init_logging(settings: &LogConfig) -> Result<Option<WorkerGuard>, Error> {
let file = settings.file.as_deref().filter(|p| !p.is_empty());
let stdout = settings.stdout || file.is_none();
let filter =
EnvFilter::try_new(settings.level.as_str()).unwrap_or_else(|_| EnvFilter::new("info"));
let stdout_layer = stdout.then(|| fmt_layer(settings.json, std::io::stdout));
let mut guard = None;
let file_layer = if let Some(path) = file {
let (writer, g) = file_writer(path)?;
guard = Some(g);
Some(fmt_layer(settings.json, writer))
} else {
None
};
let _ = tracing_subscriber::registry()
.with(filter)
.with(stdout_layer)
.with(file_layer)
.try_init();
Ok(guard)
}
fn fmt_layer<S, W>(json: bool, writer: W) -> Box<dyn Layer<S> + Send + Sync>
where
S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
W: for<'a> tracing_subscriber::fmt::MakeWriter<'a> + Send + Sync + 'static,
{
let layer = fmt::layer().with_writer(writer);
if json {
layer.json().boxed()
} else {
layer.boxed()
}
}
fn file_writer(
path: &str,
) -> Result<(tracing_appender::non_blocking::NonBlocking, WorkerGuard), Error> {
let path = Path::new(path);
let dir = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let name = path
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("app.log"));
std::fs::create_dir_all(dir)?;
let appender = tracing_appender::rolling::never(dir, name);
Ok(tracing_appender::non_blocking(appender))
}
async fn health() -> HttpResponse {
HttpResponse::Ok().body("ok")
}
async fn ready() -> HttpResponse {
if !db::is_connected() {
return HttpResponse::ServiceUnavailable().body("no database");
}
match db::ping().await {
Ok(()) => HttpResponse::Ok().body("ready"),
Err(e) => {
let body = if Environment::current().exposes_infra_errors() {
e.to_string()
} else {
"not ready".into()
};
HttpResponse::ServiceUnavailable().body(body)
}
}
}
#[cfg(test)]
pub(crate) fn test_source() -> noema::MapSource {
let url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://noema:noema@127.0.0.1:5432/noema".into());
noema::MapSource::new([("DATABASE_URL", url)])
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::{App, http::StatusCode, test};
#[actix_web::test]
async fn probes_are_opt_in() {
let app = Application::start(Modules::new(), test_source(), ApplicationConfig::default())
.await
.expect("start");
let srv = test::init_service(App::new().configure(|c| app.configure(c))).await;
let resp =
test::call_service(&srv, test::TestRequest::get().uri("/health").to_request()).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let app = app.health().ready();
let srv = test::init_service(App::new().configure(|c| app.configure(c))).await;
let health =
test::call_service(&srv, test::TestRequest::get().uri("/health").to_request()).await;
assert_eq!(health.status(), StatusCode::OK);
let ready =
test::call_service(&srv, test::TestRequest::get().uri("/ready").to_request()).await;
assert_ne!(ready.status(), StatusCode::NOT_FOUND);
}
#[actix_web::test]
async fn swagger_is_always_mounted() {
let app = Application::start(Modules::new(), test_source(), ApplicationConfig::default())
.await
.expect("start");
let srv = test::init_service(App::new().configure(|c| app.configure(c))).await;
let resp = test::call_service(
&srv,
test::TestRequest::get().uri("/swagger-ui/").to_request(),
)
.await;
assert_ne!(resp.status(), StatusCode::NOT_FOUND);
}
#[actix_web::test]
async fn start_requires_database_url() {
let err = Application::start(
Modules::new(),
noema::MapSource::new(std::iter::empty::<(String, String)>()),
ApplicationConfig::default(),
)
.await
.err()
.expect("missing url");
assert!(err.to_string().contains("DATABASE_URL"));
}
}