use std::{
net::SocketAddr,
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use anyhow::{Context, Result};
use fraiseql_core::{cache::CachedDatabaseAdapter, db::postgres::PostgresAdapter};
use fraiseql_server::{Server, ServerConfig, server_config::TlsServerConfig};
use notify::{
Config as NotifyConfig, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher,
};
use tracing::info;
use super::compile::{CompileOptions, compile_to_schema};
use crate::config::{
DatabaseRuntimeConfig, ServerRuntimeConfig, TomlProjectConfig, TomlSchema,
runtime::TlsRuntimeConfig,
};
pub async fn run(
input: Option<&str>,
database: Option<String>,
port: Option<u16>,
bind: Option<String>,
watch: bool,
introspection: bool,
) -> Result<()> {
let input_path = resolve_input(input)?;
let (db_url, bind_addr, server_cfg, db_cfg) =
resolve_runtime_config(&input_path, database, port, bind)?;
println!("FraiseQL");
println!(" Schema: {}", input_path.display());
println!(" Server: http://{bind_addr}/graphql");
println!();
if watch {
run_watch_loop(&input_path, &db_url, bind_addr, introspection, &server_cfg, &db_cfg).await
} else {
run_once(&input_path, &db_url, bind_addr, introspection, &server_cfg, &db_cfg).await
}
}
async fn run_once(
input_path: &Path,
db_url: &str,
bind_addr: SocketAddr,
introspection: bool,
server_cfg: &ServerRuntimeConfig,
db_cfg: &DatabaseRuntimeConfig,
) -> Result<()> {
let schema = compile_schema(input_path).await?;
let config = build_config_from(db_url, bind_addr, server_cfg, db_cfg, introspection);
let adapter = Arc::new(
PostgresAdapter::with_pool_config(
db_url,
fraiseql_core::db::postgres::PoolPrewarmConfig {
min_size: config.pool_min_size,
max_size: config.pool_max_size,
timeout_secs: Some(config.pool_timeout_secs),
},
)
.await
.context("Failed to connect to database")?,
);
println!("Server ready at http://{bind_addr}/graphql");
println!(" Press Ctrl+C to stop");
println!();
let server: Server<CachedDatabaseAdapter<PostgresAdapter>> =
Server::new(config, schema, adapter, None)
.await
.context("Failed to initialize server")?;
server.serve().await.context("Server error")
}
async fn run_watch_loop(
input_path: &Path,
db_url: &str,
bind_addr: SocketAddr,
introspection: bool,
server_cfg: &ServerRuntimeConfig,
db_cfg: &DatabaseRuntimeConfig,
) -> Result<()> {
loop {
let schema = compile_schema(input_path).await?;
let config = build_config_from(db_url, bind_addr, server_cfg, db_cfg, introspection);
let adapter = Arc::new(
PostgresAdapter::with_pool_config(
db_url,
fraiseql_core::db::postgres::PoolPrewarmConfig {
min_size: config.pool_min_size,
max_size: config.pool_max_size,
timeout_secs: Some(config.pool_timeout_secs),
},
)
.await
.context("Failed to connect to database")?,
);
println!("Server ready at http://{bind_addr}/graphql");
println!(" Watching {} for changes... (Ctrl+C to stop)", input_path.display());
println!();
let server: Server<CachedDatabaseAdapter<PostgresAdapter>> =
Server::new(config, schema, adapter, None)
.await
.context("Failed to initialize server")?;
let (change_tx, change_rx) = tokio::sync::oneshot::channel::<()>();
let restarting = Arc::new(AtomicBool::new(false));
let restarting_for_watcher = restarting.clone();
let watch_path = input_path.to_path_buf();
let _watcher_guard = spawn_file_watcher(watch_path, move |_event| {
restarting_for_watcher.store(true, Ordering::SeqCst);
let _ = change_tx.send(());
})?;
server
.serve_with_shutdown(async move {
tokio::select! {
() = Server::<CachedDatabaseAdapter<PostgresAdapter>>::shutdown_signal() => {},
result = change_rx => {
if result.is_err() {
}
},
}
})
.await
.context("Server error")?;
if !restarting.load(Ordering::SeqCst) {
break;
}
tokio::time::sleep(Duration::from_millis(200)).await;
println!("Schema changed, recompiling...");
}
Ok(())
}
pub(crate) fn resolve_runtime_config(
input_path: &Path,
db_cli: Option<String>,
port_cli: Option<u16>,
bind_cli: Option<String>,
) -> Result<(String, SocketAddr, ServerRuntimeConfig, DatabaseRuntimeConfig)> {
let (server_cfg, db_cfg) = load_runtime_config_from_toml(input_path)?;
let db_url = db_cli
.or_else(|| std::env::var("DATABASE_URL").ok())
.or_else(|| db_cfg.url.clone())
.ok_or_else(|| {
anyhow::anyhow!(
"No database URL provided. Use --database, set DATABASE_URL env var, \
or set [database].url in fraiseql.toml."
)
})?;
let host = bind_cli
.or_else(|| std::env::var("FRAISEQL_HOST").ok())
.unwrap_or_else(|| server_cfg.host.clone());
let port = port_cli
.or_else(|| std::env::var("FRAISEQL_PORT").ok().and_then(|v| v.parse::<u16>().ok()))
.unwrap_or(server_cfg.port);
let bind_addr: SocketAddr = format!("{host}:{port}").parse().context("Invalid bind address")?;
server_cfg.validate()?;
db_cfg.validate()?;
Ok((db_url, bind_addr, server_cfg, db_cfg))
}
fn load_runtime_config_from_toml(
input_path: &Path,
) -> Result<(ServerRuntimeConfig, DatabaseRuntimeConfig)> {
let ext = input_path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext == "toml" {
let schema =
TomlSchema::from_file(input_path.to_str().unwrap_or("")).with_context(|| {
format!("Failed to load runtime config from {}", input_path.display())
})?;
info!("Loaded [server] and [database] config from {}", input_path.display());
return Ok((schema.server, schema.database));
}
let toml_path = input_path.parent().unwrap_or(Path::new(".")).join("fraiseql.toml");
if toml_path.exists() {
match TomlProjectConfig::from_file(toml_path.to_str().unwrap_or("fraiseql.toml")) {
Ok(cfg) => {
info!("Loaded [server] and [database] config from {}", toml_path.display());
return Ok((cfg.server, cfg.database));
},
Err(e) => {
info!("Could not parse TomlProjectConfig for runtime config: {e}");
},
}
}
Ok((ServerRuntimeConfig::default(), DatabaseRuntimeConfig::default()))
}
pub(crate) fn build_config_from(
db_url: &str,
bind_addr: SocketAddr,
server: &ServerRuntimeConfig,
db_cfg: &DatabaseRuntimeConfig,
introspection: bool,
) -> ServerConfig {
let tls = server.tls.enabled.then(|| build_tls_config(&server.tls));
let mut config = ServerConfig {
database_url: db_url.to_string(),
bind_addr,
cors_enabled: true,
cors_origins: server.cors.origins.clone(),
tls,
pool_min_size: db_cfg.pool_min,
pool_max_size: db_cfg.pool_max,
pool_timeout_secs: db_cfg.connect_timeout_ms / 1000,
introspection_enabled: introspection,
introspection_require_auth: false,
..ServerConfig::default()
};
let server_args = fraiseql_server::ServerArgs::from_env();
server_args.apply_to_config(&mut config);
config
}
fn build_tls_config(tls: &TlsRuntimeConfig) -> TlsServerConfig {
TlsServerConfig {
enabled: true,
cert_path: tls.cert_file.clone().into(),
key_path: tls.key_file.clone().into(),
min_version: tls.min_version.clone(),
require_client_cert: false,
client_ca_path: None,
}
}
async fn compile_schema(path: &Path) -> Result<fraiseql_core::schema::CompiledSchema> {
let input = path.to_str().ok_or_else(|| anyhow::anyhow!("Input path is not valid UTF-8"))?;
println!("Compiling schema...");
let (schema, _report) = compile_to_schema(CompileOptions::new(input))
.await
.context("Schema compilation failed")?;
println!(
" Schema compiled ({} types, {} queries, {} mutations)",
schema.types.len(),
schema.queries.len(),
schema.mutations.len(),
);
println!();
Ok(schema)
}
fn spawn_file_watcher<F>(path: PathBuf, on_change: F) -> Result<RecommendedWatcher>
where
F: FnOnce(Event) + Send + 'static,
{
use std::sync::mpsc::channel;
let (tx, rx) = channel::<Result<Event, notify::Error>>();
let mut watcher = RecommendedWatcher::new(
move |res| {
let _ = tx.send(res);
},
NotifyConfig::default().with_poll_interval(Duration::from_millis(500)),
)
.context("Failed to create file watcher")?;
watcher
.watch(&path, RecursiveMode::NonRecursive)
.context("Failed to watch input file")?;
std::thread::spawn(move || {
for event in rx.into_iter().flatten() {
if matches!(event.kind, EventKind::Modify(_)) {
info!("Schema file changed");
on_change(event);
break;
}
}
});
Ok(watcher)
}
pub(crate) fn resolve_input(input: Option<&str>) -> Result<PathBuf> {
if let Some(path) = input {
let p = PathBuf::from(path);
if p.exists() {
return Ok(p);
}
anyhow::bail!("Input file not found: {path}");
}
auto_detect_input(&std::env::current_dir().unwrap_or_default())
}
pub(crate) fn auto_detect_input(base: &Path) -> Result<PathBuf> {
let candidates = ["fraiseql.toml", "schema.json"];
for candidate in &candidates {
let p = base.join(candidate);
if p.exists() {
info!("Auto-detected input file: {candidate}");
return Ok(p);
}
}
anyhow::bail!(
"No input file found. Create a fraiseql.toml (or schema.json) in the current \
directory, or pass an explicit path: fraiseql run <INPUT>"
)
}