#![allow(clippy::print_stdout, clippy::print_stderr)]
use std::{path::Path, sync::Arc};
use clap::Parser;
use fraiseql_core::schema::CompiledSchema;
use fraiseql_server::{
Cli, CompiledSchemaLoader, Server, ServerConfig,
usage::{aggregator::global_aggregator, layer::MutationAuditLayer},
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
fn load_config(config_path: Option<&str>) -> anyhow::Result<ServerConfig> {
if let Some(path) = config_path {
tracing::info!(path = %path, "Loading configuration from file");
let config = ServerConfig::from_file(path)
.map_err(|e| anyhow::anyhow!("failed to parse config file `{path}`: {e}"))?;
#[cfg(feature = "observers")]
check_observer_config_layout(&config, path)?;
Ok(config)
} else {
tracing::info!("Using default server configuration");
Ok(ServerConfig::default())
}
}
#[cfg(feature = "observers")]
fn check_observer_config_layout(config: &ServerConfig, path: &str) -> anyhow::Result<()> {
if let Some(observers) = &config.observers {
let misplaced = observers.misplaced_runtime_keys();
if let Some(first) = misplaced.first() {
anyhow::bail!(
"config file `{path}`: the [observers] key(s) `{}` moved to \
[observers.runtime] in v2.5.0. Move them under an \
`[observers.runtime]` table, e.g.\n\n [observers.runtime]\n {first} = ...\n",
misplaced.join("`, `"),
);
}
}
Ok(())
}
fn validate_schema_path(path: &Path) -> anyhow::Result<()> {
if !path.exists() {
anyhow::bail!(
"Schema file not found: {}. \
Please compile schema first with: fraiseql-cli compile schema.json",
path.display()
);
}
Ok(())
}
fn init_tracing(config: &ServerConfig, is_json: bool) {
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "fraiseql_server=info,tower_http=info,axum=info".into());
let audit_layer = MutationAuditLayer::new(Arc::clone(global_aggregator()));
if is_json {
let subscriber = tracing_subscriber::registry()
.with(env_filter)
.with(audit_layer)
.with(tracing_subscriber::fmt::layer().json());
#[cfg(feature = "tracing-opentelemetry")]
let subscriber = subscriber.with(build_otlp_layer(config));
#[cfg(not(feature = "tracing-opentelemetry"))]
let _ = config;
subscriber.init();
} else {
let subscriber = tracing_subscriber::registry()
.with(env_filter)
.with(audit_layer)
.with(tracing_subscriber::fmt::layer());
#[cfg(feature = "tracing-opentelemetry")]
let subscriber = subscriber.with(build_otlp_layer(config));
#[cfg(not(feature = "tracing-opentelemetry"))]
let _ = config;
subscriber.init();
}
}
#[cfg(feature = "tracing-opentelemetry")]
fn redact_endpoint_credentials(endpoint: &str) -> String {
match url::Url::parse(endpoint) {
Ok(mut parsed) => {
if !parsed.username().is_empty() || parsed.password().is_some() {
let _ = parsed.set_username("[REDACTED]");
let _ = parsed.set_password(None);
}
parsed.to_string()
},
Err(_) => endpoint.to_string(),
}
}
#[cfg(feature = "tracing-opentelemetry")]
fn resolve_otlp_endpoint(config: &ServerConfig) -> Option<String> {
config
.otlp_endpoint
.clone()
.or_else(|| std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok())
}
#[cfg(feature = "tracing-opentelemetry")]
fn build_otlp_layer<S>(
config: &ServerConfig,
) -> Option<tracing_opentelemetry::OpenTelemetryLayer<S, opentelemetry_sdk::trace::Tracer>>
where
S: tracing::Subscriber + for<'span> tracing_subscriber::registry::LookupSpan<'span>,
{
use opentelemetry::trace::TracerProvider as _;
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::trace::SdkTracerProvider;
let endpoint = resolve_otlp_endpoint(config)?;
let exporter = opentelemetry_otlp::SpanExporter::builder()
.with_http()
.with_endpoint(&endpoint)
.with_timeout(std::time::Duration::from_secs(config.otlp_export_timeout_secs))
.build()
.map_err(|e| {
eprintln!(
"Failed to build OTLP exporter for {}: {e}",
redact_endpoint_credentials(&endpoint)
);
})
.ok()?;
let provider = SdkTracerProvider::builder()
.with_batch_exporter(exporter)
.with_resource(
opentelemetry_sdk::Resource::builder()
.with_service_name(config.tracing_service_name.clone())
.build(),
)
.build();
let tracer = provider.tracer("fraiseql");
eprintln!(
"OTLP tracing export enabled: endpoint={}, service_name={}",
redact_endpoint_credentials(&endpoint),
config.tracing_service_name
);
Some(tracing_opentelemetry::layer().with_tracer(tracer))
}
fn load_and_validate_config(cli: &Cli) -> anyhow::Result<ServerConfig> {
let mut config = load_config(cli.server.config.as_deref())?;
cli.server.apply_to_config(&mut config);
if let Err(e) = config.validate() {
tracing::error!(error = %e, "Configuration validation failed");
anyhow::bail!(e);
}
Ok(config)
}
async fn load_schema(config: &ServerConfig) -> anyhow::Result<LoadedSchema> {
validate_schema_path(&config.schema_path)?;
let schema_loader = CompiledSchemaLoader::new(&config.schema_path);
let extended = schema_loader.load_extended().await?;
fraiseql_core::utils::casing::set_runtime_acronyms(&extended.schema.naming_acronyms);
tracing::info!("Compiled schema loaded successfully");
Ok(LoadedSchema {
schema: extended.schema,
#[cfg(feature = "functions-runtime")]
functions: extended.functions,
})
}
struct LoadedSchema {
schema: CompiledSchema,
#[cfg(feature = "functions-runtime")]
functions: Option<fraiseql_server::schema::loader::FunctionsConfig>,
}
#[cfg(feature = "secrets")]
async fn build_secrets_manager()
-> anyhow::Result<Option<Arc<fraiseql_server::secrets_manager::SecretsManager>>> {
use fraiseql_server::secrets_manager::{SecretsBackendConfig, VaultAuth};
let Ok(backend) = std::env::var("FRAISEQL_SECRETS_BACKEND") else {
tracing::debug!("Secrets manager disabled (set FRAISEQL_SECRETS_BACKEND to enable)");
return Ok(None);
};
let env_or_bail = |name: &str| -> anyhow::Result<String> {
std::env::var(name).map_err(|_| {
anyhow::anyhow!(
"FRAISEQL_SECRETS_BACKEND={backend} requires {name} to be set; refusing to \
boot without it rather than running with a partially configured secrets \
backend"
)
})
};
let cfg = match backend.as_str() {
"env" => SecretsBackendConfig::Env,
"file" => SecretsBackendConfig::File {
path: std::path::PathBuf::from(env_or_bail("FRAISEQL_SECRETS_FILE_PATH")?),
},
"vault" => {
let addr = env_or_bail("VAULT_ADDR")?;
let auth = match (std::env::var("VAULT_TOKEN"), std::env::var("VAULT_ROLE_ID")) {
(Ok(token), _) => VaultAuth::Token(zeroize::Zeroizing::new(token)),
(Err(_), Ok(role_id)) => VaultAuth::AppRole {
role_id,
secret_id: zeroize::Zeroizing::new(env_or_bail("VAULT_SECRET_ID")?),
},
(Err(_), Err(_)) => anyhow::bail!(
"FRAISEQL_SECRETS_BACKEND=vault requires either VAULT_TOKEN (static \
token) or VAULT_ROLE_ID + VAULT_SECRET_ID (AppRole login)"
),
};
SecretsBackendConfig::Vault {
addr,
auth,
namespace: std::env::var("VAULT_NAMESPACE").ok(),
tls_verify: !std::env::var("VAULT_TLS_VERIFY")
.is_ok_and(|v| v.eq_ignore_ascii_case("false")),
}
},
other => anyhow::bail!(
"unknown FRAISEQL_SECRETS_BACKEND {other:?}; expected \"env\", \"file\" or \
\"vault\". (Every value used to silently select the env backend, so a \
deployment that asked for Vault ran on environment variables while its \
startup log read healthy.)"
),
};
tracing::info!(backend = %backend, "Initializing secrets manager");
match fraiseql_server::secrets_manager::create_secrets_manager(cfg).await {
Ok(manager) => Ok(Some(manager)),
Err(e) => {
tracing::error!(error = %e, "Failed to initialize secrets manager");
anyhow::bail!("Secrets manager initialization failed: {}", e)
},
}
}
#[cfg(not(feature = "secrets"))]
async fn build_secrets_manager() -> anyhow::Result<Option<std::convert::Infallible>> {
if std::env::var("FRAISEQL_SECRETS_BACKEND").is_ok() {
anyhow::bail!(
"FRAISEQL_SECRETS_BACKEND is set, but this binary was built without the `secrets` \
feature, so no secrets backend can be initialized. Rebuild with \
`--features secrets`, or unset FRAISEQL_SECRETS_BACKEND to run without a secrets \
manager."
);
}
Ok(None)
}
fn warn_files_not_wired(config: &ServerConfig) {
if config.files.is_empty() {
return;
}
let mut names: Vec<&str> = config.files.keys().map(String::as_str).collect();
names.sort_unstable();
tracing::warn!(
sections = %names.join(", "),
"[files.<name>] sections are configured but file-upload routes are not yet wired into \
the fraiseql-server binary; these sections are ignored."
);
}
#[tokio::main(flavor = "multi_thread")]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let config = load_and_validate_config(&cli)?;
init_tracing(&config, cli.server.is_json_log_format());
tracing::info!("FraiseQL Server v{}", env!("CARGO_PKG_VERSION"));
tracing::info!(
bind_addr = %config.bind_addr,
database_url = %config.database_url,
graphql_path = %config.graphql_path,
health_path = %config.health_path,
introspection_path = %config.introspection_path,
metrics_enabled = config.metrics_enabled,
"Server configuration loaded"
);
#[cfg(feature = "metrics")]
if config.metrics_enabled {
fraiseql_server::metrics_recorder::install();
}
let loaded = load_schema(&config).await?;
warn_files_not_wired(&config);
Box::pin(dispatch_server(config, loaded, &cli)).await
}
async fn dispatch_server(
config: ServerConfig,
loaded: LoadedSchema,
cli: &Cli,
) -> anyhow::Result<()> {
use fraiseql_server::url_guard::{DatabaseScheme, parse_database_url};
match parse_database_url(&config.database_url)? {
DatabaseScheme::Postgres => Box::pin(run_postgres(config, loaded, cli)).await,
}
}
async fn run_postgres(config: ServerConfig, loaded: LoadedSchema, cli: &Cli) -> anyhow::Result<()> {
let LoadedSchema {
schema,
#[cfg(feature = "functions-runtime")]
functions,
} = loaded;
#[cfg(not(feature = "functions-runtime"))]
let functions = None;
let server =
Box::pin(fraiseql_server::boot::build_postgres_server(config, schema, functions)).await?;
finish_server(server, cli, cfg!(feature = "arrow")).await
}
#[cfg_attr(not(feature = "mcp"), allow(unused_variables))]
async fn finish_server(server: Server, cli: &Cli, with_arrow: bool) -> anyhow::Result<()> {
#[cfg(feature = "secrets")]
let mut server = server;
#[cfg(feature = "secrets")]
if let Some(mgr) = build_secrets_manager().await? {
server.set_secrets_manager(mgr);
}
#[cfg(not(feature = "secrets"))]
let _ = build_secrets_manager().await?;
#[cfg(feature = "mcp")]
if cli.mcp_stdio.is_some() {
tracing::info!("FraiseQL MCP stdio mode starting");
Box::pin(server.serve_mcp_stdio()).await?;
return Ok(());
}
if with_arrow {
tracing::info!(
"FraiseQL Server {} starting (HTTP + Arrow Flight)",
env!("CARGO_PKG_VERSION")
);
} else {
tracing::info!("FraiseQL Server {} starting (HTTP only)", env!("CARGO_PKG_VERSION"));
}
Box::pin(server.serve()).await?;
Ok(())
}