use std::{
net::SocketAddr,
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use anyhow::{Context, Result};
use fraiseql_core::{db::postgres::PostgresAdapter, schema::CompiledSchema};
use fraiseql_server::{
Server, ServerConfig,
server_config::{DatabaseTlsConfig, TlsServerConfig},
url_guard::{DatabaseScheme, parse_database_url},
};
use notify::{
Config as NotifyConfig, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher,
};
use tracing::{info, warn};
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 {
Box::pin(run_watch_loop(
&input_path,
&db_url,
bind_addr,
introspection,
&server_cfg,
&db_cfg,
))
.await
} else {
Box::pin(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 scheme = parse_database_url(db_url)?;
let schema = compile_schema(input_path).await?;
let config = build_config_from(db_url, bind_addr, server_cfg, db_cfg, introspection)?;
println!("Server ready at http://{bind_addr}/graphql");
println!(" Press Ctrl+C to stop");
println!();
Box::pin(dispatch_serve(scheme, config, schema, None)).await
}
async fn run_watch_loop(
input_path: &Path,
db_url: &str,
bind_addr: SocketAddr,
introspection: bool,
server_cfg: &ServerRuntimeConfig,
db_cfg: &DatabaseRuntimeConfig,
) -> Result<()> {
let scheme = parse_database_url(db_url)?;
loop {
let schema = compile_schema(input_path).await?;
let config = build_config_from(db_url, bind_addr, server_cfg, db_cfg, introspection)?;
println!("Server ready at http://{bind_addr}/graphql");
println!(" Watching {} for changes... (Ctrl+C to stop)", input_path.display());
println!();
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(());
})?;
Box::pin(dispatch_serve(
scheme,
config,
schema,
Some(Box::pin(async move {
tokio::select! {
() = sigint_signal() => {},
result = change_rx => {
if result.is_err() {
}
},
}
})),
))
.await?;
if !restarting.load(Ordering::SeqCst) {
break;
}
tokio::time::sleep(Duration::from_millis(200)).await;
println!("Schema changed, recompiling...");
}
Ok(())
}
async fn sigint_signal() {
let _ = tokio::signal::ctrl_c().await;
}
type ShutdownFuture =
Option<std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'static>>>;
async fn dispatch_serve(
scheme: DatabaseScheme,
config: ServerConfig,
schema: CompiledSchema,
shutdown: ShutdownFuture,
) -> Result<()> {
match scheme {
DatabaseScheme::Postgres => Box::pin(serve_postgres(config, schema, shutdown)).await,
}
}
async fn serve_postgres(
config: ServerConfig,
schema: CompiledSchema,
shutdown: ShutdownFuture,
) -> Result<()> {
let tls = config.postgres_tls().map_err(|e| anyhow::anyhow!(e))?;
tls.warn_if_unverified();
let adapter = Arc::new(
PostgresAdapter::with_pool_config(
&config.database_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),
search_path: None,
tls: tls.clone(),
read_replicas: config.read_replicas(),
max_streaming_reads: config.pool_max_streaming_reads,
vector_scan: config.vector_scan(),
},
)
.await
.with_context(|| {
format!("Failed to connect to database (ssl_mode = {})", tls.effective_mode())
})?,
);
let server: Server =
Box::pin(Server::new(config, schema, adapter, None))
.await
.context("Failed to initialize server")?;
Box::pin(finish_serve(server, shutdown)).await
}
async fn finish_serve(server: Server, shutdown: ShutdownFuture) -> Result<()> {
if let Some(shutdown) = shutdown {
server.serve_with_shutdown(shutdown).await.context("Server error")
} else {
Box::pin(server.serve()).await.context("Server error")
}
}
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());
warn_ignored_config_sections(input_path);
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());
warn_ignored_config_sections(&toml_path);
return Ok((cfg.server, cfg.database));
},
Err(e) => {
warn!(
"Found {} but could not parse it for runtime config ({e}); \
falling back to default [server]/[database] settings",
toml_path.display()
);
},
}
}
Ok((ServerRuntimeConfig::default(), DatabaseRuntimeConfig::default()))
}
const RUN_IGNORED_CONFIG_SECTIONS: &[&str] = &[
"auth",
"federation",
"observers",
"enrichment",
"tenancy",
"storage",
"security",
];
pub(crate) fn ignored_config_sections(contents: &str) -> Vec<&'static str> {
let Ok(table) = toml::from_str::<toml::Table>(contents) else {
return Vec::new();
};
RUN_IGNORED_CONFIG_SECTIONS
.iter()
.copied()
.filter(|section| table.contains_key(*section))
.collect()
}
fn warn_ignored_config_sections(config_path: &Path) {
let Ok(contents) = std::fs::read_to_string(config_path) else {
return;
};
let present = ignored_config_sections(&contents);
if present.is_empty() {
return;
}
let named = present.iter().map(|s| format!("[{s}]")).collect::<Vec<_>>().join(", ");
warn!(
ignored_sections = %present.join(", "),
"`fraiseql run` is a development quick-launcher that consumes only [server] and \
[database]; the config section(s) {named} in {} are ignored. For a deployment \
that honors them, run the `fraiseql-server` binary (shipped in the -full release \
tarball) with `--config {}`.",
config_path.display(),
config_path.display(),
);
}
pub(crate) fn build_config_from(
db_url: &str,
bind_addr: SocketAddr,
server: &ServerRuntimeConfig,
db_cfg: &DatabaseRuntimeConfig,
introspection: bool,
) -> Result<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,
database_tls: Some(DatabaseTlsConfig {
postgres_ssl_mode: db_cfg.ssl_mode.clone(),
verify_certificates: true,
ca_bundle_path: None,
redis_ssl: None,
clickhouse_https: None,
elasticsearch_https: None,
}),
pool_min_size: db_cfg.pool_min,
pool_max_size: db_cfg.pool_max,
pool_timeout_secs: db_cfg.pool_timeout_secs(),
introspection_enabled: introspection,
introspection_require_auth: false,
..ServerConfig::default()
};
let server_args = fraiseql_server::ServerArgs::from_env()
.map_err(|e| anyhow::anyhow!("invalid server environment variable: {e}"))?;
server_args.apply_to_config(&mut config);
Ok(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 (artifact, _report) = compile_to_schema(CompileOptions::new(input))
.await
.context("Schema compilation failed")?;
let schema = artifact.schema;
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>"
)
}