#![allow(clippy::print_stdout, clippy::print_stderr)]
use std::{path::Path, sync::Arc};
use clap::Parser;
#[cfg(feature = "wire-backend")]
use fraiseql_core::db::FraiseWireAdapter;
#[cfg(not(feature = "wire-backend"))]
use fraiseql_core::db::postgres::PostgresAdapter;
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 = "auth")]
fn init_security(schema: &CompiledSchema) -> anyhow::Result<()> {
tracing::info!("Initializing security configuration from schema");
let schema_json_str = schema.to_json().unwrap_or_else(|e| {
tracing::warn!(error = %e, "Failed to serialize schema to JSON");
"{}".to_string()
});
let security_config = fraiseql_server::auth::init_security_config(&schema_json_str)
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "Failed to load security config from schema, using defaults");
fraiseql_server::auth::init_default_security_config()
});
if let Err(e) = fraiseql_server::auth::validate_security_config(&security_config) {
tracing::error!(error = %e, "Security configuration validation failed");
anyhow::bail!(e);
}
fraiseql_server::auth::log_security_config(&security_config);
Ok(())
}
#[cfg(not(feature = "auth"))]
fn init_security(_schema: &CompiledSchema) -> anyhow::Result<()> {
Ok(())
}
#[cfg(not(feature = "wire-backend"))]
async fn build_postgres_adapter(config: &ServerConfig) -> anyhow::Result<Arc<PostgresAdapter>> {
let tls = config.postgres_tls().map_err(|e| anyhow::anyhow!(e))?;
tls.warn_if_unverified();
tracing::info!(
pool_min_size = config.pool_min_size,
pool_max_size = config.pool_max_size,
pool_timeout_secs = config.pool_timeout_secs,
postgres_ssl_mode = %tls.effective_mode(),
"Initializing PostgreSQL connection pool"
);
let adapter = 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,
read_replicas: config.read_replicas(),
max_streaming_reads: config.pool_max_streaming_reads,
vector_scan: config.vector_scan(),
},
)
.await?;
tracing::info!("PostgreSQL adapter ready");
Ok(Arc::new(adapter))
}
#[cfg(feature = "wire-backend")]
async fn build_wire_adapter(config: &ServerConfig) -> anyhow::Result<Arc<FraiseWireAdapter>> {
config.wire_backend_rejects_read_replicas().map_err(|e| anyhow::anyhow!(e))?;
tracing::info!(
database_url = %config.database_url,
"Initializing FraiseQL Wire database adapter (low-memory streaming)"
);
let adapter = FraiseWireAdapter::new(&config.database_url);
tracing::info!("FraiseQL Wire adapter initialized successfully");
Ok(Arc::new(adapter))
}
#[cfg(all(not(feature = "wire-backend"), feature = "observers"))]
async fn build_observer_pool(config: &ServerConfig) -> anyhow::Result<Option<sqlx::PgPool>> {
use std::time::Duration;
use sqlx::postgres::PgPoolOptions;
let pool_cfg = config.observers.as_ref().map(|o| o.runtime.pool.clone()).unwrap_or_default();
tracing::info!(
min = pool_cfg.min_connections,
max = pool_cfg.max_connections,
timeout_secs = pool_cfg.acquire_timeout_secs,
"Initializing observer PostgreSQL pool"
);
let pool = PgPoolOptions::new()
.min_connections(pool_cfg.min_connections)
.max_connections(pool_cfg.max_connections)
.acquire_timeout(Duration::from_secs(pool_cfg.acquire_timeout_secs))
.connect(&config.database_url)
.await?;
Ok(Some(pool))
}
#[cfg(all(not(feature = "wire-backend"), not(feature = "observers")))]
async fn build_observer_pool(_config: &ServerConfig) -> anyhow::Result<Option<sqlx::PgPool>> {
Ok(None)
}
#[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."
);
}
#[cfg(feature = "wire-backend")]
fn warn_storage_requires_postgres(config: &ServerConfig, database: &str) {
if config.storage.is_empty() {
return;
}
tracing::warn!(
database,
"[storage.<name>] is configured but object storage via the binary is PostgreSQL-only \
(the metadata repository requires PostgreSQL); storage routes will NOT be mounted."
);
}
#[cfg(feature = "wire-backend")]
fn warn_revocation_requires_postgres(schema: &CompiledSchema, database: &str) {
if fraiseql_server::token_revocation::revocation_backend_is_postgres(schema) {
tracing::warn!(
database,
"[security.token_revocation] backend = \"postgres\" but this binary is not running on \
PostgreSQL; token revocation will NOT be active. Set backend = \"memory\" or \
\"redis\", or deploy on PostgreSQL."
);
}
}
#[cfg(feature = "wire-backend")]
fn warn_tenant_provisioning_requires_postgres(config: &ServerConfig, database: &str) {
if config.tenancy.runtime.enabled {
tracing::warn!(
database,
"[tenancy.runtime] is enabled but runtime tenant provisioning \
(PUT /api/v1/admin/tenants) is PostgreSQL-only; dispatch to \
pre-registered tenants still works, but registration will fail."
);
}
}
#[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?;
init_security(&loaded.schema)?;
warn_files_not_wired(&config);
Box::pin(dispatch_server(config, loaded, &cli)).await
}
#[cfg(feature = "wire-backend")]
async fn dispatch_server(
config: ServerConfig,
loaded: LoadedSchema,
cli: &Cli,
) -> anyhow::Result<()> {
let LoadedSchema {
schema,
#[cfg(feature = "functions-runtime")]
functions,
} = loaded;
warn_storage_requires_postgres(&config, "wire-backend");
warn_tenant_provisioning_requires_postgres(&config, "wire-backend");
warn_revocation_requires_postgres(&schema, "wire-backend");
let adapter = build_wire_adapter(&config).await?;
let server = Server::new_read_only(config, schema, adapter, None).await?;
#[cfg(feature = "functions-runtime")]
let server = server.with_functions_config(functions);
Box::pin(finish_server(server, cli, false)).await
}
#[cfg(not(feature = "wire-backend"))]
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,
}
}
#[cfg(not(feature = "wire-backend"))]
async fn run_postgres(config: ServerConfig, loaded: LoadedSchema, cli: &Cli) -> anyhow::Result<()> {
let LoadedSchema {
schema,
#[cfg(feature = "functions-runtime")]
functions,
} = loaded;
let adapter = build_postgres_adapter(&config).await?;
if config.validate_sql_sources {
use fraiseql_server::sql_source_check as check;
let unbacked = check::find_unbacked_sources(&schema, adapter.as_ref()).await?;
let unusable = check::find_unusable_sources(&schema, adapter.as_ref()).await?;
if !unbacked.is_empty() || !unusable.is_empty() {
let role = check::connected_role(adapter.as_ref()).await?;
anyhow::bail!("{}", check::format_source_problems(&unbacked, &unusable, &role));
}
tracing::info!(
sources = schema.queries.len() + schema.mutations.len(),
"sql_source validation passed: all declared sources are backed"
);
}
let db_pool = build_observer_pool(&config).await?;
let storage_state = fraiseql_server::server_config::build_storage_state(&config)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
if let Some(state) = &storage_state {
tracing::info!(
buckets = state.buckets.load().len(),
"Object storage configured; mounting /storage/v1/*"
);
}
let pg_revocation_manager =
fraiseql_server::token_revocation::build_postgres_revocation_manager(
&config.database_url,
&schema,
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let tenancy_runtime_enabled = config.tenancy.runtime.enabled;
let database_tls = config.postgres_tls().map_err(|e| anyhow::anyhow!(e))?;
let tenant_read_replica_policy = config.read_replica_policy();
let tenant_vector_scan = config.vector_scan();
#[cfg(feature = "arrow")]
{
use fraiseql_server::arrow::create_flight_service;
let flight_service = create_flight_service(adapter.clone(), &config.flight_upload_tables);
fraiseql_server::arrow::register_flight_views(&flight_service, &config.flight_views).await;
if config.flight_upload_tables.is_empty() {
tracing::info!(
"Arrow Flight service initialized with real database adapter (Upload disabled: \
flight_upload_tables is empty)"
);
} else {
tracing::warn!(
tables = ?config.flight_upload_tables,
"Arrow Flight service initialized with real database adapter; Upload is ENABLED \
for these tables — clients may INSERT into them without passing the mutation \
pipeline's authorizer or field RBAC"
);
}
let server =
Server::with_flight_service(config, schema, adapter, db_pool, Some(flight_service))
.await?;
#[cfg(feature = "functions-runtime")]
let server = server.with_functions_config(functions);
let tenant_factory = tenancy_runtime_enabled.then(|| {
fraiseql_server::tenancy::make_executor_factory::<
fraiseql_core::cache::CachedDatabaseAdapter<PostgresAdapter>,
>(
database_tls.clone(), tenant_read_replica_policy.clone(), tenant_vector_scan
)
});
let server = match storage_state {
Some(state) => server.with_storage_state(state),
None => server,
};
let server = match tenant_factory {
Some(factory) => server.with_tenant_executor_factory(factory),
None => server,
};
let server = match pg_revocation_manager {
Some(manager) => server.with_revocation_manager(manager),
None => server,
};
let server = enable_rest_writes(server);
return finish_server(server, cli, true).await;
}
#[cfg(not(feature = "arrow"))]
{
let has_relay_queries = schema.queries.iter().any(|q| q.relay);
let server = if has_relay_queries {
Server::with_relay_pagination(config, schema, adapter, db_pool).await?
} else {
Server::new(config, schema, adapter, db_pool).await?
};
#[cfg(feature = "functions-runtime")]
let server = server.with_functions_config(functions);
let tenant_factory = tenancy_runtime_enabled.then(|| {
fraiseql_server::tenancy::make_executor_factory::<
fraiseql_core::cache::CachedDatabaseAdapter<PostgresAdapter>,
>(
database_tls.clone(), tenant_read_replica_policy.clone(), tenant_vector_scan
)
});
let server = match storage_state {
Some(state) => server.with_storage_state(state),
None => server,
};
let server = match tenant_factory {
Some(factory) => server.with_tenant_executor_factory(factory),
None => server,
};
let server = match pg_revocation_manager {
Some(manager) => server.with_revocation_manager(manager),
None => server,
};
let server = enable_rest_writes(server);
finish_server(server, cli, false).await
}
}
#[cfg(all(not(feature = "wire-backend"), feature = "rest"))]
fn enable_rest_writes(server: Server) -> Server {
server.with_rest_write_surface()
}
#[cfg(all(not(feature = "wire-backend"), not(feature = "rest")))]
const fn enable_rest_writes(server: Server) -> Server {
server
}
#[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(())
}