use std::fmt;
use std::path::{Path, PathBuf};
use autumn_web::config::Env;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub const ENV_PREFIX: &str = "AUTUMN_GRAPHQL__";
pub const DEFAULT_SECTION: &str = "graphql";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Toggle {
#[default]
Auto,
On,
Off,
}
impl Toggle {
#[must_use]
pub const fn resolve(self, auto: bool) -> bool {
match self {
Self::Auto => auto,
Self::On => true,
Self::Off => false,
}
}
}
impl From<bool> for Toggle {
fn from(value: bool) -> Self {
if value { Self::On } else { Self::Off }
}
}
impl Serialize for Toggle {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::Auto => serializer.serialize_str("auto"),
Self::On => serializer.serialize_bool(true),
Self::Off => serializer.serialize_bool(false),
}
}
}
impl<'de> Deserialize<'de> for Toggle {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Bool(bool),
Text(String),
}
match Raw::deserialize(deserializer)? {
Raw::Bool(value) => Ok(value.into()),
Raw::Text(text) => match text.trim().to_ascii_lowercase().as_str() {
"auto" => Ok(Self::Auto),
"true" | "on" | "enabled" | "yes" => Ok(Self::On),
"false" | "off" | "disabled" | "no" => Ok(Self::Off),
other => Err(serde::de::Error::custom(format!(
"expected true, false or \"auto\", found \"{other}\""
))),
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PersistedQueryMode {
#[default]
Disabled,
Automatic,
Trusted,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
#[non_exhaustive]
pub struct BatchingConfig {
pub enabled: bool,
pub max_operations: usize,
}
impl Default for BatchingConfig {
fn default() -> Self {
Self {
enabled: false,
max_operations: 10,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
#[non_exhaustive]
pub struct LimitsConfig {
pub max_query_bytes: usize,
pub max_nesting: usize,
pub max_depth: usize,
pub max_aliases: usize,
pub max_root_fields: usize,
pub max_fields: usize,
pub max_directives: usize,
pub max_complexity: usize,
}
impl Default for LimitsConfig {
fn default() -> Self {
Self {
max_query_bytes: 32 * 1024,
max_nesting: 64,
max_depth: 15,
max_aliases: 30,
max_root_fields: 30,
max_fields: 1_000,
max_directives: 50,
max_complexity: 0,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
#[non_exhaustive]
pub struct SubscriptionsConfig {
pub websocket: Toggle,
pub sse: Toggle,
pub max_connections: usize,
pub keepalive_secs: u64,
pub init_timeout_secs: u64,
}
impl Default for SubscriptionsConfig {
fn default() -> Self {
Self {
websocket: Toggle::Auto,
sse: Toggle::Auto,
max_connections: 10_000,
keepalive_secs: 30,
init_timeout_secs: 10,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
#[non_exhaustive]
pub struct PersistedQueriesConfig {
pub mode: PersistedQueryMode,
pub cache_capacity: usize,
pub manifest: String,
}
impl Default for PersistedQueriesConfig {
fn default() -> Self {
Self {
mode: PersistedQueryMode::Disabled,
cache_capacity: 1_000,
manifest: String::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
#[non_exhaustive]
pub struct UploadsConfig {
pub enabled: bool,
pub max_files: usize,
pub max_file_bytes: usize,
}
impl Default for UploadsConfig {
fn default() -> Self {
Self {
enabled: false,
max_files: 10,
max_file_bytes: 10 * 1024 * 1024,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
#[non_exhaustive]
pub struct GraphqlConfig {
pub enabled: bool,
pub path: String,
pub introspection: Toggle,
pub sdl: Toggle,
pub mask_unexpected_errors: Toggle,
pub allow_get: bool,
pub max_body_bytes: usize,
pub timeout_ms: u64,
pub slow_operation_ms: u64,
pub csrf_prevention: bool,
pub batching: BatchingConfig,
pub limits: LimitsConfig,
pub subscriptions: SubscriptionsConfig,
pub persisted_queries: PersistedQueriesConfig,
pub uploads: UploadsConfig,
}
impl Default for GraphqlConfig {
fn default() -> Self {
Self {
enabled: true,
path: "/graphql".to_owned(),
introspection: Toggle::Auto,
sdl: Toggle::Auto,
mask_unexpected_errors: Toggle::Auto,
allow_get: true,
max_body_bytes: 1024 * 1024,
timeout_ms: 30_000,
slow_operation_ms: 1_000,
csrf_prevention: true,
batching: BatchingConfig::default(),
limits: LimitsConfig::default(),
subscriptions: SubscriptionsConfig::default(),
persisted_queries: PersistedQueriesConfig::default(),
uploads: UploadsConfig::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("invalid [graphql] configuration: {0}")]
pub struct ConfigError(pub String);
impl GraphqlConfig {
pub fn from_toml_str(text: &str, section: &str) -> Result<Self, ConfigError> {
let document: toml::Table = toml::from_str(text).map_err(|e| ConfigError(e.to_string()))?;
let config = Self::from_section(document.get(section))?;
config.validate()?;
Ok(config)
}
fn from_section(section: Option<&toml::Value>) -> Result<Self, ConfigError> {
section.map_or_else(
|| Ok(Self::default()),
|value| {
value
.clone()
.try_into()
.map_err(|e: toml::de::Error| ConfigError(e.to_string()))
},
)
}
pub fn resolve(section: &str) -> Result<Resolved, ConfigError> {
autumn_web::dotenv::os_env_with_dotenv().map_or_else(
|_| Self::resolve_with_env(section, &autumn_web::config::OsEnv),
|env| Self::resolve_with_env(section, &env),
)
}
pub fn resolve_with_env(section: &str, env: &dyn Env) -> Result<Resolved, ConfigError> {
let (selected, canonical) = resolve_active_profile(env);
let mut merged = toml::Value::Table(toml::map::Map::new());
let base = read_optional_toml(&find_config_file("autumn.toml", env))?;
if let Some(base) = &base {
deep_merge(&mut merged, base.clone());
for name in profile_inline_lookup_names(&canonical) {
if let Some(profile) = profile_section(base, name) {
deep_merge(&mut merged, profile);
}
}
}
for name in autumn_web::config::profile_override_file_lookup_names(&canonical, &selected) {
let path = find_config_file(&format!("autumn-{name}.toml"), env);
if let Some(overlay) = read_optional_toml(&path)? {
deep_merge(&mut merged, overlay);
break;
}
}
let mut section_value = merged
.get(section)
.cloned()
.unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()));
if !section_value.is_table() {
return Err(ConfigError(format!("`{section}` must be a table")));
}
let mut config = Self::from_section(Some(§ion_value))?;
apply_env_overrides(&mut section_value, &mut config, env);
config.validate()?;
Ok(Resolved {
config,
profile: canonical,
base_dir: config_base_dir(env),
})
}
pub fn validate(&self) -> Result<(), ConfigError> {
let path = self.path.trim();
if !path.starts_with('/') || path.len() < 2 || path.ends_with('/') {
return Err(ConfigError(format!(
"graphql.path must start with `/`, must not end with `/`, and must not be the \
root (got {:?})",
self.path
)));
}
if path.contains(['{', '}', '*', '?', '#', ' ']) {
return Err(ConfigError(format!(
"graphql.path must be a literal path (got {:?})",
self.path
)));
}
if self.max_body_bytes == 0 {
return Err(ConfigError(
"graphql.max_body_bytes must be at least 1".into(),
));
}
if self.batching.enabled && self.batching.max_operations == 0 {
return Err(ConfigError(
"graphql.batching.max_operations must be at least 1 when batching is enabled"
.into(),
));
}
if self.uploads.enabled && (self.uploads.max_files == 0 || self.uploads.max_file_bytes == 0)
{
return Err(ConfigError(
"graphql.uploads.max_files and max_file_bytes must be at least 1 when uploads \
are enabled"
.into(),
));
}
if self.persisted_queries.mode == PersistedQueryMode::Automatic
&& self.persisted_queries.cache_capacity == 0
{
return Err(ConfigError(
"graphql.persisted_queries.cache_capacity must be at least 1 in automatic mode"
.into(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolved {
pub config: GraphqlConfig,
pub profile: String,
pub base_dir: PathBuf,
}
impl Resolved {
#[must_use]
pub fn explicit(config: GraphqlConfig) -> Self {
let env = autumn_web::dotenv::os_env_with_dotenv().ok();
let (profile, base_dir) = env.as_ref().map_or_else(
|| {
let os = autumn_web::config::OsEnv;
(resolve_active_profile(&os).1, config_base_dir(&os))
},
|env| (resolve_active_profile(env).1, config_base_dir(env)),
);
Self {
config,
profile,
base_dir,
}
}
#[must_use]
pub fn is_development(&self) -> bool {
matches!(self.profile.as_str(), "dev" | "test")
}
}
fn apply_env_overrides(section: &mut toml::Value, config: &mut GraphqlConfig, env: &dyn Env) {
let Ok(defaults) = toml::Value::try_from(GraphqlConfig::default()) else {
return;
};
let mut leaves = Vec::new();
collect_leaves(&defaults, &mut Vec::new(), &mut leaves);
for path in leaves {
let key = format!(
"{ENV_PREFIX}{}",
path.iter()
.map(|segment| segment.to_ascii_uppercase())
.collect::<Vec<_>>()
.join("__")
);
let Some(raw) = env_trimmed(env, &key) else {
continue;
};
let value = parse_env_value(&raw);
let mut candidate = section.clone();
set_path(&mut candidate, &path, value);
match GraphqlConfig::from_section(Some(&candidate)) {
Ok(parsed) => {
*section = candidate;
*config = parsed;
}
Err(error) => tracing::warn!(
variable = %key,
%error,
"ignoring an [graphql] environment override that does not type-check"
),
}
}
}
fn parse_env_value(raw: &str) -> toml::Value {
toml::from_str::<toml::Table>(&format!("v = {raw}"))
.ok()
.and_then(|mut table| table.remove("v"))
.unwrap_or_else(|| toml::Value::String(raw.to_owned()))
}
fn collect_leaves(value: &toml::Value, prefix: &mut Vec<String>, out: &mut Vec<Vec<String>>) {
if let toml::Value::Table(table) = value {
for (key, child) in table {
prefix.push(key.clone());
collect_leaves(child, prefix, out);
prefix.pop();
}
} else {
out.push(prefix.clone());
}
}
fn set_path(root: &mut toml::Value, path: &[String], value: toml::Value) {
let Some((last, parents)) = path.split_last() else {
return;
};
let mut cursor = root;
for segment in parents {
let Some(table) = cursor.as_table_mut() else {
return;
};
cursor = table
.entry(segment.clone())
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
}
if let Some(table) = cursor.as_table_mut() {
table.insert(last.clone(), value);
}
}
fn env_trimmed(env: &dyn Env, key: &str) -> Option<String> {
env.var(key)
.ok()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
}
fn resolve_active_profile(env: &dyn Env) -> (String, String) {
let selected = resolve_profile_input(env);
let canonical =
autumn_web::config::normalize_profile_name(&selected).unwrap_or_else(|| "dev".to_owned());
(selected, canonical)
}
fn resolve_profile_input(env: &dyn Env) -> String {
if let Some(value) = env_trimmed(env, "AUTUMN_ENV") {
return value;
}
if let Some(value) = env_trimmed(env, "AUTUMN_PROFILE") {
return value;
}
let args: Vec<String> = std::env::args().collect();
for (index, arg) in args.iter().enumerate() {
if arg == "--profile"
&& let Some(profile) = args.get(index.saturating_add(1))
&& !profile.trim().is_empty()
{
return profile.trim().to_owned();
}
if let Some(profile) = arg.strip_prefix("--profile=")
&& !profile.trim().is_empty()
{
return profile.trim().to_owned();
}
}
if env_trimmed(env, "AUTUMN_IS_DEBUG").as_deref() == Some("0") {
return "prod".to_owned();
}
"dev".to_owned()
}
fn find_config_file(filename: &str, env: &dyn Env) -> PathBuf {
if let Some(dir) = env_trimmed(env, "AUTUMN_MANIFEST_DIR") {
let candidate = PathBuf::from(dir).join(filename);
if candidate.exists() {
return candidate;
}
}
PathBuf::from(filename)
}
fn config_base_dir(env: &dyn Env) -> PathBuf {
find_config_file("autumn.toml", env)
.parent()
.map(Path::to_path_buf)
.filter(|dir| !dir.as_os_str().is_empty())
.unwrap_or_else(|| PathBuf::from("."))
}
fn read_optional_toml(path: &Path) -> Result<Option<toml::Value>, ConfigError> {
match std::fs::read_to_string(path) {
Ok(contents) => {
let table = toml::from_str::<toml::Table>(&contents)
.map_err(|e| ConfigError(format!("cannot parse {}: {e}", path.display())))?;
Ok(Some(toml::Value::Table(table)))
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(ConfigError(format!(
"cannot read {}: {error}",
path.display()
))),
}
}
fn profile_inline_lookup_names(canonical: &str) -> Vec<&str> {
match canonical {
"prod" => vec!["production", "prod"],
"dev" => vec!["development", "dev"],
other => vec![other],
}
}
fn profile_section(base: &toml::Value, profile: &str) -> Option<toml::Value> {
base.get("profile")
.and_then(toml::Value::as_table)
.and_then(|profiles| profiles.get(profile))
.and_then(toml::Value::as_table)
.map(|table| toml::Value::Table(table.clone()))
}
fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
deep_merge_at(base, overlay, 0);
}
fn deep_merge_at(base: &mut toml::Value, overlay: toml::Value, depth: usize) {
const MAX_MERGE_DEPTH: usize = 16;
if depth > MAX_MERGE_DEPTH {
return;
}
let toml::Value::Table(overlay_table) = overlay else {
return;
};
let Some(base_table) = base.as_table_mut() else {
return;
};
for (key, overlay_value) in overlay_table {
let recurse =
overlay_value.is_table() && base_table.get(&key).is_some_and(toml::Value::is_table);
if recurse {
if let Some(base_value) = base_table.get_mut(&key) {
deep_merge_at(base_value, overlay_value, depth.saturating_add(1));
}
} else {
base_table.insert(key, overlay_value);
}
}
}
impl fmt::Display for Toggle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Auto => "auto",
Self::On => "true",
Self::Off => "false",
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use autumn_web::config::MockEnv;
use super::*;
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"autumn-graphql-config-{name}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn defaults_are_production_safe() {
let config = GraphqlConfig::default();
assert!(config.enabled);
assert_eq!(config.path, "/graphql");
assert_eq!(config.introspection, Toggle::Auto);
assert!(!config.batching.enabled);
assert!(!config.uploads.enabled);
assert!(config.csrf_prevention);
assert_eq!(config.limits.max_depth, 15);
config.validate().unwrap();
}
#[test]
fn a_missing_section_is_the_defaults() {
assert_eq!(
GraphqlConfig::from_toml_str("[server]\nport = 3000\n", "graphql").unwrap(),
GraphqlConfig::default()
);
assert_eq!(
GraphqlConfig::from_toml_str("", "graphql").unwrap(),
GraphqlConfig::default()
);
}
#[test]
fn a_partial_section_keeps_the_other_defaults() {
let config = GraphqlConfig::from_toml_str(
"[graphql]\nintrospection = false\n[graphql.limits]\nmax_depth = 7\n",
"graphql",
)
.unwrap();
assert_eq!(config.introspection, Toggle::Off);
assert_eq!(config.limits.max_depth, 7);
assert_eq!(config.limits.max_aliases, 30);
assert_eq!(config.path, "/graphql");
}
#[test]
fn typos_and_wrong_types_are_errors_not_defaults() {
assert!(
GraphqlConfig::from_toml_str("[graphql]\nintrospectoin = false\n", "graphql").is_err()
);
assert!(
GraphqlConfig::from_toml_str("[graphql]\nintrospection = \"flase\"\n", "graphql")
.is_err()
);
assert!(
GraphqlConfig::from_toml_str("[graphql.limits]\nmax_depth = \"x\"\n", "graphql")
.is_err()
);
assert!(GraphqlConfig::from_toml_str("[graphql\n", "graphql").is_err());
}
#[test]
fn toggles_accept_booleans_and_words() {
for (text, expected) in [
("true", Toggle::On),
("false", Toggle::Off),
("\"auto\"", Toggle::Auto),
("\"on\"", Toggle::On),
("\"disabled\"", Toggle::Off),
] {
let config =
GraphqlConfig::from_toml_str(&format!("[graphql]\nsdl = {text}\n"), "graphql")
.unwrap();
assert_eq!(config.sdl, expected, "{text}");
}
assert!(Toggle::Auto.resolve(true));
assert!(!Toggle::Auto.resolve(false));
assert!(Toggle::On.resolve(false));
assert!(!Toggle::Off.resolve(true));
}
#[test]
fn invalid_values_are_rejected() {
for bad in [
"[graphql]\npath = \"graphql\"\n",
"[graphql]\npath = \"/\"\n",
"[graphql]\npath = \"/graphql/\"\n",
"[graphql]\npath = \"/{id}\"\n",
"[graphql]\nmax_body_bytes = 0\n",
"[graphql.batching]\nenabled = true\nmax_operations = 0\n",
"[graphql.uploads]\nenabled = true\nmax_files = 0\n",
"[graphql.persisted_queries]\nmode = \"automatic\"\ncache_capacity = 0\n",
"[graphql.persisted_queries]\nmode = \"sometimes\"\n",
] {
assert!(
GraphqlConfig::from_toml_str(bad, "graphql").is_err(),
"should reject: {bad}"
);
}
}
#[test]
fn profile_layers_and_env_override_in_order() {
let dir = temp_dir("layers");
std::fs::write(
dir.join("autumn.toml"),
"[graphql]\npath = \"/api/graphql\"\nintrospection = true\ntimeout_ms = 5\n\
[graphql.limits]\nmax_depth = 20\n\
[profile.prod.graphql]\nintrospection = false\n",
)
.unwrap();
std::fs::write(
dir.join("autumn-prod.toml"),
"[graphql.limits]\nmax_depth = 12\n",
)
.unwrap();
let env = MockEnv::new()
.with("AUTUMN_MANIFEST_DIR", dir.to_str().unwrap())
.with("AUTUMN_ENV", "prod")
.with("AUTUMN_GRAPHQL__TIMEOUT_MS", "250")
.with("AUTUMN_GRAPHQL__LIMITS__MAX_ALIASES", "3")
.with("AUTUMN_GRAPHQL__PERSISTED_QUERIES__MODE", "trusted");
let resolved = GraphqlConfig::resolve_with_env("graphql", &env).unwrap();
assert_eq!(resolved.profile, "prod");
assert!(!resolved.is_development());
let config = resolved.config;
assert_eq!(config.path, "/api/graphql", "base layer");
assert_eq!(config.introspection, Toggle::Off, "inline profile layer");
assert_eq!(config.limits.max_depth, 12, "profile file layer");
assert_eq!(config.timeout_ms, 250, "env layer");
assert_eq!(config.limits.max_aliases, 3, "nested env layer");
assert_eq!(config.persisted_queries.mode, PersistedQueryMode::Trusted);
assert_eq!(resolved.base_dir, dir);
let dev = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.to_str().unwrap());
let resolved = GraphqlConfig::resolve_with_env("graphql", &dev).unwrap();
assert_eq!(resolved.profile, "dev");
assert!(resolved.is_development());
assert_eq!(resolved.config.introspection, Toggle::On);
assert_eq!(resolved.config.limits.max_depth, 20);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_malformed_env_override_is_ignored() {
let env = MockEnv::new()
.with("AUTUMN_MANIFEST_DIR", "/nonexistent-autumn-graphql")
.with("AUTUMN_GRAPHQL__LIMITS__MAX_DEPTH", "deep")
.with("AUTUMN_GRAPHQL__INTROSPECTION", "auto")
.with("AUTUMN_GRAPHQL__ENABLED", "false");
let config = GraphqlConfig::resolve_with_env("graphql", &env)
.unwrap()
.config;
assert_eq!(config.limits.max_depth, 15);
assert_eq!(config.introspection, Toggle::Auto);
assert!(!config.enabled);
}
#[test]
fn a_custom_section_name_is_read() {
let config = GraphqlConfig::from_toml_str(
"[graphql]\npath = \"/a\"\n[graphql_admin]\npath = \"/admin/graphql\"\n",
"graphql_admin",
)
.unwrap();
assert_eq!(config.path, "/admin/graphql");
}
#[test]
fn an_unreadable_file_is_an_error() {
let dir = temp_dir("unparseable");
std::fs::write(dir.join("autumn.toml"), "[graphql\n").unwrap();
let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.to_str().unwrap());
assert!(GraphqlConfig::resolve_with_env("graphql", &env).is_err());
let _ = std::fs::remove_dir_all(dir);
}
}