use std::{
collections::BTreeSet,
net::SocketAddr,
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use anyhow::{Context, Result};
use fraiseql_core::schema::CompiledSchema;
use fraiseql_server::{
Server, ServerConfig,
schema::loader::FunctionsConfig,
server_config::{DatabaseTlsConfig, TlsServerConfig},
url_guard::{DatabaseScheme, parse_database_url},
};
use notify::{
Config as NotifyConfig, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher,
};
use tracing::info;
use super::{
compile::{CompileOptions, compile_to_schema},
run_provision,
};
use crate::config::{
ConfigSource, 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,
admin_database_url: Option<String>,
) -> Result<()> {
let admin_url = run_provision::admin_url(admin_database_url);
if admin_url.is_some() {
run_provision::refuse_outside_development()?;
}
let input_path = resolve_input(input)?;
let server_file = ServerFile::from_env()?;
let (db_url, bind_addr, server_cfg, db_cfg) =
resolve_runtime_config(&input_path, database, port, bind, server_file.as_ref())?;
println!("FraiseQL");
println!(" Schema: {}", input_path.display());
println!(" Server: http://{bind_addr}/graphql");
println!();
let serving = Serving {
input_path: &input_path,
db_url: &db_url,
bind_addr,
introspection,
server_cfg: &server_cfg,
db_cfg: &db_cfg,
server_file: server_file.as_ref(),
admin_url: admin_url.as_deref(),
};
if watch {
Box::pin(run_watch_loop(&serving)).await
} else {
Box::pin(run_once(&serving)).await
}
}
#[derive(Clone, Copy)]
struct Serving<'a> {
input_path: &'a Path,
db_url: &'a str,
bind_addr: SocketAddr,
introspection: bool,
server_cfg: &'a ServerRuntimeConfig,
db_cfg: &'a DatabaseRuntimeConfig,
server_file: Option<&'a ServerFile>,
admin_url: Option<&'a str>,
}
async fn run_once(serving: &Serving<'_>) -> Result<()> {
let Serving {
input_path,
db_url,
bind_addr,
introspection,
server_cfg,
db_cfg,
server_file,
admin_url,
} = *serving;
let scheme = parse_database_url(db_url)?;
let compiled = compile_schema(input_path).await?;
provision_if_asked(admin_url, db_url, &compiled).await?;
let config =
build_config_from(db_url, bind_addr, server_cfg, db_cfg, introspection, server_file)?;
println!("Server ready at http://{bind_addr}/graphql");
println!(" Press Ctrl+C to stop");
println!();
Box::pin(dispatch_serve(scheme, config, compiled, None)).await
}
async fn run_watch_loop(serving: &Serving<'_>) -> Result<()> {
let Serving {
input_path,
db_url,
bind_addr,
introspection,
server_cfg,
db_cfg,
server_file,
admin_url,
} = *serving;
let scheme = parse_database_url(db_url)?;
loop {
let compiled = compile_schema(input_path).await?;
provision_if_asked(admin_url, db_url, &compiled).await?;
let config =
build_config_from(db_url, bind_addr, server_cfg, db_cfg, introspection, server_file)?;
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,
compiled,
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 provision_if_asked(
admin_url: Option<&str>,
db_url: &str,
compiled: &Compiled,
) -> Result<()> {
let Some(admin_url) = admin_url else {
return Ok(());
};
run_provision::provision(
admin_url,
db_url,
"the database URL (--database, DATABASE_URL or [database].url)",
&compiled.schema,
)
.await
}
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,
compiled: Compiled,
shutdown: ShutdownFuture,
) -> Result<()> {
match scheme {
DatabaseScheme::Postgres => Box::pin(serve_postgres(config, compiled, shutdown)).await,
}
}
async fn serve_postgres(
config: ServerConfig,
compiled: Compiled,
shutdown: ShutdownFuture,
) -> Result<()> {
let server = Box::pin(build_server(config, compiled.schema, compiled.functions)).await?;
Box::pin(finish_serve(server, shutdown)).await
}
pub(crate) async fn build_server(
config: ServerConfig,
schema: CompiledSchema,
functions: Option<FunctionsConfig>,
) -> Result<Server> {
Box::pin(fraiseql_server::boot::build_postgres_server(config, schema, functions))
.await
.context("Failed to initialize server")
}
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>,
server_file: Option<&ServerFile>,
) -> Result<(String, SocketAddr, ServerRuntimeConfig, DatabaseRuntimeConfig)> {
let (server_cfg, db_cfg, project) = load_runtime_config_from_toml(input_path)?;
if let Some(file) = server_file {
file.refuse_settings_made_twice(&project)?;
}
let from_file = |key: &str| server_file.filter(|file| file.sets(key)).map(|file| &file.config);
let db_url = db_cli
.or_else(|| std::env::var("DATABASE_URL").ok())
.or_else(|| db_cfg.url.clone())
.or_else(|| from_file("database_url").map(|config| config.database_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 file_bind = from_file("bind_addr").map(|config| config.bind_addr);
let host = bind_cli
.or_else(|| std::env::var("FRAISEQL_HOST").ok())
.or_else(|| file_bind.map(|addr| addr.ip().to_string()))
.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()))
.or_else(|| file_bind.map(|addr| addr.port()))
.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, BTreeSet<String>)> {
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, settings_made(input_path)?));
}
let source = ConfigSource::resolve(input_path, None)?;
let Some(toml_path) = source.project_config() else {
info!("Project config: {source}");
return Ok((
ServerRuntimeConfig::default(),
DatabaseRuntimeConfig::default(),
BTreeSet::new(),
));
};
let cfg = TomlProjectConfig::from_file(toml_path.to_str().unwrap_or_default())
.with_context(|| format!("Failed to load runtime config from {}", toml_path.display()))?;
info!("Loaded [server] and [database] config from {}", toml_path.display());
Ok((cfg.server, cfg.database, settings_made(toml_path)?))
}
fn settings_made(toml_path: &Path) -> Result<BTreeSet<String>> {
let text = std::fs::read_to_string(toml_path)
.with_context(|| format!("Failed to read {}", toml_path.display()))?;
let table: toml::Table = text
.parse()
.with_context(|| format!("Failed to parse {}", toml_path.display()))?;
let mut made = BTreeSet::new();
for section in ["server", "database"] {
let Some(toml::Value::Table(keys)) = table.get(section) else {
continue;
};
for (key, value) in keys {
made.insert(format!("{section}.{key}"));
if let toml::Value::Table(nested) = value {
made.extend(nested.keys().map(|inner| format!("{section}.{key}.{inner}")));
}
}
}
Ok(made)
}
pub(crate) struct ServerFile {
path: String,
keys: BTreeSet<String>,
config: ServerConfig,
}
pub(crate) const SETTINGS_IN_BOTH: &[(&str, &str)] = &[
("server.host", "bind_addr"),
("server.port", "bind_addr"),
("server.cors.origins", "cors_origins"),
("server.tls", "tls"),
("database.url", "database_url"),
("database.pool_min", "pool_min_size"),
("database.pool_max", "pool_max_size"),
("database.connect_timeout_ms", "pool_timeout_secs"),
("database.ssl_mode", "database_tls"),
("database.transaction_retry", "transaction_retry"),
];
impl ServerFile {
pub(crate) fn from_env() -> Result<Option<Self>> {
std::env::var("FRAISEQL_CONFIG").ok().map(|path| Self::load(&path)).transpose()
}
pub(crate) fn load(path: &str) -> Result<Self> {
let config = ServerConfig::from_file(path)
.map_err(|e| anyhow::anyhow!("failed to parse config file `{path}`: {e}"))?;
let table: toml::Table = std::fs::read_to_string(path)?.parse()?;
Ok(Self {
path: path.to_owned(),
keys: table.keys().cloned().collect(),
config,
})
}
fn sets(&self, key: &str) -> bool {
self.keys.contains(key)
}
fn refuse_settings_made_twice(&self, project: &BTreeSet<String>) -> Result<()> {
for (setting, key) in SETTINGS_IN_BOTH {
let made = project
.iter()
.any(|made| made == setting || made.starts_with(&format!("{setting}.")));
if made && self.sets(key) {
let (section, name) = setting.split_once('.').unwrap_or(("", setting));
anyhow::bail!(
"`[{section}] {name}` in fraiseql.toml and `{key}` in {} (FRAISEQL_CONFIG) \
both set the same thing: set it in one of them",
self.path
);
}
}
Ok(())
}
}
pub(crate) fn build_config_from(
db_url: &str,
bind_addr: SocketAddr,
server: &ServerRuntimeConfig,
db_cfg: &DatabaseRuntimeConfig,
introspection: bool,
server_file: Option<&ServerFile>,
) -> Result<ServerConfig> {
let mut config = server_file.map_or_else(ServerConfig::default, |file| file.config.clone());
let unset = |key: &str| !server_file.is_some_and(|file| file.sets(key));
config.database_url = db_url.to_string();
config.bind_addr = bind_addr;
if unset("cors_enabled") {
config.cors_enabled = true;
}
if unset("cors_origins") {
config.cors_origins.clone_from(&server.cors.origins);
}
if unset("tls") {
config.tls = server.tls.enabled.then(|| build_tls_config(&server.tls));
}
if unset("database_tls") {
config.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,
});
}
if unset("pool_min_size") {
config.pool_min_size = db_cfg.pool_min;
}
if unset("pool_max_size") {
config.pool_max_size = db_cfg.pool_max;
}
if unset("pool_timeout_secs") {
config.pool_timeout_secs = db_cfg.pool_timeout_secs();
}
if unset("transaction_retry") {
config.transaction_retry.clone_from(&db_cfg.transaction_retry);
}
if introspection {
config.introspection_enabled = true;
config.introspection_require_auth = false;
} else {
if unset("introspection_enabled") {
config.introspection_enabled = false;
}
if unset("introspection_require_auth") {
config.introspection_require_auth = false;
}
}
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,
}
}
#[derive(Debug)]
pub(crate) struct Compiled {
pub(crate) schema: CompiledSchema,
pub(crate) functions: Option<FunctionsConfig>,
}
pub(crate) async fn compile_schema(path: &Path) -> Result<Compiled> {
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")?;
fraiseql_server::boot::refuse_functions_this_build_cannot_run(artifact.functions.as_ref())?;
let schema = artifact.schema;
let raw = serde_json::to_value(&schema).context("Failed to serialize the compiled schema")?;
fraiseql_server::schema::loader::refuse_unservable(&raw)?;
println!(
" Schema compiled ({} types, {} queries, {} mutations)",
schema.types.len(),
schema.queries.len(),
schema.mutations.len(),
);
println!();
Ok(Compiled {
schema,
functions: artifact.functions,
})
}
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>"
)
}