Skip to main content

autumn_plugin_graphql/
config.rs

1//! The plugin-owned `[graphql]` section of `autumn.toml`.
2//!
3//! Every knob has a production-safe default, so a zero-config mount is already
4//! hardened. The section is resolved with the same layering Autumn core uses
5//! for its own configuration:
6//!
7//! 1. base `autumn.toml` `[graphql]`,
8//! 2. inline `[profile.<name>.graphql]` (legacy alias first, canonical last),
9//! 3. `autumn-<profile>.toml` `[graphql]` (first existing file wins),
10//! 4. `AUTUMN_GRAPHQL__*` environment variables (including `.env` files).
11//!
12//! Reading only layer 1 would make `introspection = false` under
13//! `[profile.prod.graphql]` a silent no-op in production — exactly where it is
14//! needed — so the plugin pays for the full merge.
15//!
16//! ```toml
17//! [graphql]
18//! path = "/graphql"
19//! introspection = "auto"          # auto = on in dev/test, off elsewhere
20//! mask_unexpected_errors = "auto" # auto = off in dev/test, on elsewhere
21//! timeout_ms = 30000
22//!
23//! [graphql.limits]
24//! max_depth = 15
25//! max_aliases = 30
26//!
27//! [graphql.persisted_queries]
28//! mode = "trusted"                # only allow-listed operations run
29//! manifest = "persisted-queries.json"
30//! ```
31
32use std::fmt;
33use std::path::{Path, PathBuf};
34
35use autumn_web::config::Env;
36use serde::{Deserialize, Deserializer, Serialize, Serializer};
37
38/// Environment-variable prefix for `[graphql]` overrides.
39pub const ENV_PREFIX: &str = "AUTUMN_GRAPHQL__";
40
41/// The default config root this plugin reads.
42pub const DEFAULT_SECTION: &str = "graphql";
43
44/// A three-state switch: forced on, forced off, or decided by context
45/// (the active profile, or whether the schema has a subscription root).
46///
47/// In TOML it is written as a boolean or the string `"auto"`:
48///
49/// ```toml
50/// introspection = false
51/// sdl = "auto"
52/// ```
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54pub enum Toggle {
55    /// Decided by context; see each field's documentation.
56    #[default]
57    Auto,
58    /// Always on.
59    On,
60    /// Always off.
61    Off,
62}
63
64impl Toggle {
65    /// Resolve against the context-derived default.
66    #[must_use]
67    pub const fn resolve(self, auto: bool) -> bool {
68        match self {
69            Self::Auto => auto,
70            Self::On => true,
71            Self::Off => false,
72        }
73    }
74}
75
76impl From<bool> for Toggle {
77    fn from(value: bool) -> Self {
78        if value { Self::On } else { Self::Off }
79    }
80}
81
82impl Serialize for Toggle {
83    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
84        match self {
85            Self::Auto => serializer.serialize_str("auto"),
86            Self::On => serializer.serialize_bool(true),
87            Self::Off => serializer.serialize_bool(false),
88        }
89    }
90}
91
92impl<'de> Deserialize<'de> for Toggle {
93    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
94        #[derive(Deserialize)]
95        #[serde(untagged)]
96        enum Raw {
97            Bool(bool),
98            Text(String),
99        }
100        match Raw::deserialize(deserializer)? {
101            Raw::Bool(value) => Ok(value.into()),
102            Raw::Text(text) => match text.trim().to_ascii_lowercase().as_str() {
103                "auto" => Ok(Self::Auto),
104                "true" | "on" | "enabled" | "yes" => Ok(Self::On),
105                "false" | "off" | "disabled" | "no" => Ok(Self::Off),
106                other => Err(serde::de::Error::custom(format!(
107                    "expected true, false or \"auto\", found \"{other}\""
108                ))),
109            },
110        }
111    }
112}
113
114/// How persisted operations are treated.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum PersistedQueryMode {
118    /// Free-form documents only. A hash-only request is refused with
119    /// `PERSISTED_QUERY_NOT_SUPPORTED` so the client falls back to sending
120    /// the full document.
121    #[default]
122    Disabled,
123    /// Apollo Automatic Persisted Queries: a client may send only a
124    /// `sha256Hash`; on a miss it re-sends the document, which is verified
125    /// and cached. Free-form documents are still accepted. Trusted documents
126    /// from a manifest are served too.
127    Automatic,
128    /// Trusted documents (a safelist): only operations whose hash is in the
129    /// manifest may run, whether the client sends the hash or the full text.
130    /// Everything else is refused with `OPERATION_NOT_ALLOWLISTED`.
131    Trusted,
132}
133
134/// Request batching (`[ {..}, {..} ]` bodies).
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(deny_unknown_fields, default)]
137#[non_exhaustive]
138pub struct BatchingConfig {
139    /// Accept JSON-array batch bodies. Off by default: a batch multiplies the
140    /// work one HTTP request can demand.
141    pub enabled: bool,
142    /// The most operations one batch may carry.
143    pub max_operations: usize,
144}
145
146impl Default for BatchingConfig {
147    fn default() -> Self {
148        Self {
149            enabled: false,
150            max_operations: 10,
151        }
152    }
153}
154
155/// Static analysis limits enforced on every document before it executes.
156///
157/// `0` means "unlimited" for every field. The defaults admit any realistic
158/// application query while refusing the pathological shapes used to exhaust
159/// a GraphQL server (deep nesting, alias amplification, fragment fan-out).
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(deny_unknown_fields, default)]
162#[non_exhaustive]
163pub struct LimitsConfig {
164    /// Largest accepted document, in bytes.
165    pub max_query_bytes: usize,
166    /// Deepest lexical nesting of `{`, `(` and `[`, checked before parsing so
167    /// the parser itself is never handed a stack-exhausting input.
168    pub max_nesting: usize,
169    /// Deepest selection-set nesting of the executed operation, with
170    /// fragments expanded. `{ a { b } }` has depth 2.
171    pub max_depth: usize,
172    /// Most aliased fields in the executed operation, with fragments expanded.
173    pub max_aliases: usize,
174    /// Most fields selected directly on the operation's root type.
175    pub max_root_fields: usize,
176    /// Most field selections in the executed operation after fragment
177    /// expansion — the guard against fragment fan-out ("fragment bombs").
178    pub max_fields: usize,
179    /// Most directives in the document.
180    pub max_directives: usize,
181    /// Schema-level complexity limit, applied when the plugin builds the
182    /// schema itself (`GraphqlPlugin::from_builder`). Uses async-graphql's
183    /// `#[graphql(complexity = ..)]` cost model. `0` disables it.
184    pub max_complexity: usize,
185}
186
187impl Default for LimitsConfig {
188    fn default() -> Self {
189        Self {
190            max_query_bytes: 32 * 1024,
191            max_nesting: 64,
192            max_depth: 15,
193            max_aliases: 30,
194            max_root_fields: 30,
195            max_fields: 1_000,
196            max_directives: 50,
197            max_complexity: 0,
198        }
199    }
200}
201
202/// Subscriptions over WebSocket and Server-Sent Events.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(deny_unknown_fields, default)]
205#[non_exhaustive]
206pub struct SubscriptionsConfig {
207    /// Mount `GET {path}/ws` (`graphql-transport-ws` and legacy `graphql-ws`).
208    /// `auto` = on when the schema has a subscription root.
209    pub websocket: Toggle,
210    /// Serve operations as `text/event-stream` when the client asks for it
211    /// (GraphQL over SSE, "distinct connections" mode). `auto` = on when the
212    /// schema has a subscription root.
213    pub sse: Toggle,
214    /// Most concurrent WebSocket + SSE streams per endpoint; `0` = unlimited.
215    pub max_connections: usize,
216    /// Keep-alive interval, in seconds, for WebSocket pings and SSE comments.
217    pub keepalive_secs: u64,
218    /// Seconds a WebSocket client has to send `connection_init` before the
219    /// server closes it with `4408`.
220    pub init_timeout_secs: u64,
221}
222
223impl Default for SubscriptionsConfig {
224    fn default() -> Self {
225        Self {
226            websocket: Toggle::Auto,
227            sse: Toggle::Auto,
228            max_connections: 10_000,
229            keepalive_secs: 30,
230            init_timeout_secs: 10,
231        }
232    }
233}
234
235/// Persisted operations: Automatic Persisted Queries and trusted documents.
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(deny_unknown_fields, default)]
238#[non_exhaustive]
239pub struct PersistedQueriesConfig {
240    /// See [`PersistedQueryMode`].
241    pub mode: PersistedQueryMode,
242    /// Capacity of the in-memory APQ cache (least recently used evicted).
243    pub cache_capacity: usize,
244    /// Path to a trusted-document manifest (Apollo
245    /// `persisted-query-manifest.json`, or a flat `{ "<sha256>": "<doc>" }`
246    /// map). Empty = none. Relative paths resolve like `autumn.toml`.
247    pub manifest: String,
248}
249
250impl Default for PersistedQueriesConfig {
251    fn default() -> Self {
252        Self {
253            mode: PersistedQueryMode::Disabled,
254            cache_capacity: 1_000,
255            manifest: String::new(),
256        }
257    }
258}
259
260/// `multipart/form-data` uploads (the GraphQL multipart request spec).
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(deny_unknown_fields, default)]
263#[non_exhaustive]
264pub struct UploadsConfig {
265    /// Accept multipart requests. Off by default.
266    pub enabled: bool,
267    /// Most files per request.
268    pub max_files: usize,
269    /// Largest single file, in bytes.
270    pub max_file_bytes: usize,
271}
272
273impl Default for UploadsConfig {
274    fn default() -> Self {
275        Self {
276            enabled: false,
277            max_files: 10,
278            max_file_bytes: 10 * 1024 * 1024,
279        }
280    }
281}
282
283/// The `[graphql]` section.
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(deny_unknown_fields, default)]
286#[non_exhaustive]
287pub struct GraphqlConfig {
288    /// Kill switch. `false` mounts nothing (the routes 404).
289    pub enabled: bool,
290    /// Mount path of the endpoint.
291    pub path: String,
292    /// Allow introspection (`__schema`, `__type`). `auto` = on in the `dev`
293    /// and `test` profiles, off elsewhere. `__typename` is always allowed.
294    pub introspection: Toggle,
295    /// Serve `GET {path}/sdl`. `auto` follows the effective `introspection`
296    /// value: publishing the SDL is publishing the schema.
297    pub sdl: Toggle,
298    /// Replace the message of any resolver error that does not carry an
299    /// `extensions.code` with `Internal server error` (the original is
300    /// logged). `auto` = off in `dev`/`test`, on elsewhere.
301    pub mask_unexpected_errors: Toggle,
302    /// Serve queries over `GET {path}?query=…`. Mutations over `GET` are
303    /// always refused with `405`.
304    pub allow_get: bool,
305    /// Largest accepted request body, in bytes (multipart uploads are
306    /// bounded separately by `[graphql.uploads]`).
307    pub max_body_bytes: usize,
308    /// Per-operation execution timeout in milliseconds; `0` = none.
309    /// Subscriptions are not timed out.
310    pub timeout_ms: u64,
311    /// Operations slower than this (milliseconds) are logged at `WARN`;
312    /// `0` = never.
313    pub slow_operation_ms: u64,
314    /// Require a preflight-forcing header (`apollo-require-preflight`,
315    /// `graphql-require-preflight` or `x-apollo-operation-name`) on
316    /// multipart requests, which browsers otherwise send cross-site without
317    /// a CORS preflight.
318    pub csrf_prevention: bool,
319    /// See [`BatchingConfig`].
320    pub batching: BatchingConfig,
321    /// See [`LimitsConfig`].
322    pub limits: LimitsConfig,
323    /// See [`SubscriptionsConfig`].
324    pub subscriptions: SubscriptionsConfig,
325    /// See [`PersistedQueriesConfig`].
326    pub persisted_queries: PersistedQueriesConfig,
327    /// See [`UploadsConfig`].
328    pub uploads: UploadsConfig,
329}
330
331impl Default for GraphqlConfig {
332    fn default() -> Self {
333        Self {
334            enabled: true,
335            path: "/graphql".to_owned(),
336            introspection: Toggle::Auto,
337            sdl: Toggle::Auto,
338            mask_unexpected_errors: Toggle::Auto,
339            allow_get: true,
340            max_body_bytes: 1024 * 1024,
341            timeout_ms: 30_000,
342            slow_operation_ms: 1_000,
343            csrf_prevention: true,
344            batching: BatchingConfig::default(),
345            limits: LimitsConfig::default(),
346            subscriptions: SubscriptionsConfig::default(),
347            persisted_queries: PersistedQueriesConfig::default(),
348            uploads: UploadsConfig::default(),
349        }
350    }
351}
352
353/// A configuration problem. Boot is refused rather than falling back to
354/// defaults: a typo'd `introspection = flase` must not silently leave
355/// introspection on in production.
356#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
357#[error("invalid [graphql] configuration: {0}")]
358pub struct ConfigError(pub String);
359
360impl GraphqlConfig {
361    /// Parse the `[section]` table out of a whole `autumn.toml` document,
362    /// with no profile layering and no environment overrides.
363    ///
364    /// # Errors
365    ///
366    /// Returns [`ConfigError`] for invalid TOML, unknown keys, wrong types,
367    /// or values [`validate`](Self::validate) rejects.
368    pub fn from_toml_str(text: &str, section: &str) -> Result<Self, ConfigError> {
369        let document: toml::Table = toml::from_str(text).map_err(|e| ConfigError(e.to_string()))?;
370        let config = Self::from_section(document.get(section))?;
371        config.validate()?;
372        Ok(config)
373    }
374
375    fn from_section(section: Option<&toml::Value>) -> Result<Self, ConfigError> {
376        section.map_or_else(
377            || Ok(Self::default()),
378            |value| {
379                value
380                    .clone()
381                    .try_into()
382                    .map_err(|e: toml::de::Error| ConfigError(e.to_string()))
383            },
384        )
385    }
386
387    /// Resolve `[section]` from the host app's configuration files and
388    /// environment, exactly as Autumn core layers its own settings.
389    ///
390    /// # Errors
391    ///
392    /// Returns [`ConfigError`] when a contributing file cannot be read or
393    /// parsed, when the merged section has an unknown or ill-typed key, or
394    /// when a value is out of range.
395    pub fn resolve(section: &str) -> Result<Resolved, ConfigError> {
396        autumn_web::dotenv::os_env_with_dotenv().map_or_else(
397            |_| Self::resolve_with_env(section, &autumn_web::config::OsEnv),
398            |env| Self::resolve_with_env(section, &env),
399        )
400    }
401
402    /// Pure core of [`resolve`](Self::resolve), reading only `env`.
403    ///
404    /// # Errors
405    ///
406    /// See [`resolve`](Self::resolve).
407    pub fn resolve_with_env(section: &str, env: &dyn Env) -> Result<Resolved, ConfigError> {
408        let (selected, canonical) = resolve_active_profile(env);
409        let mut merged = toml::Value::Table(toml::map::Map::new());
410
411        let base = read_optional_toml(&find_config_file("autumn.toml", env))?;
412        if let Some(base) = &base {
413            deep_merge(&mut merged, base.clone());
414            for name in profile_inline_lookup_names(&canonical) {
415                if let Some(profile) = profile_section(base, name) {
416                    deep_merge(&mut merged, profile);
417                }
418            }
419        }
420        for name in autumn_web::config::profile_override_file_lookup_names(&canonical, &selected) {
421            let path = find_config_file(&format!("autumn-{name}.toml"), env);
422            if let Some(overlay) = read_optional_toml(&path)? {
423                deep_merge(&mut merged, overlay);
424                break;
425            }
426        }
427
428        let mut section_value = merged
429            .get(section)
430            .cloned()
431            .unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()));
432        if !section_value.is_table() {
433            return Err(ConfigError(format!("`{section}` must be a table")));
434        }
435        let mut config = Self::from_section(Some(&section_value))?;
436        apply_env_overrides(&mut section_value, &mut config, env);
437        config.validate()?;
438        Ok(Resolved {
439            config,
440            profile: canonical,
441            base_dir: config_base_dir(env),
442        })
443    }
444
445    /// Reject values that would misbehave at runtime.
446    ///
447    /// # Errors
448    ///
449    /// Returns [`ConfigError`] naming the offending key.
450    pub fn validate(&self) -> Result<(), ConfigError> {
451        let path = self.path.trim();
452        if !path.starts_with('/') || path.len() < 2 || path.ends_with('/') {
453            return Err(ConfigError(format!(
454                "graphql.path must start with `/`, must not end with `/`, and must not be the \
455                 root (got {:?})",
456                self.path
457            )));
458        }
459        if path.contains(['{', '}', '*', '?', '#', ' ']) {
460            return Err(ConfigError(format!(
461                "graphql.path must be a literal path (got {:?})",
462                self.path
463            )));
464        }
465        if self.max_body_bytes == 0 {
466            return Err(ConfigError(
467                "graphql.max_body_bytes must be at least 1".into(),
468            ));
469        }
470        if self.batching.enabled && self.batching.max_operations == 0 {
471            return Err(ConfigError(
472                "graphql.batching.max_operations must be at least 1 when batching is enabled"
473                    .into(),
474            ));
475        }
476        if self.uploads.enabled && (self.uploads.max_files == 0 || self.uploads.max_file_bytes == 0)
477        {
478            return Err(ConfigError(
479                "graphql.uploads.max_files and max_file_bytes must be at least 1 when uploads \
480                 are enabled"
481                    .into(),
482            ));
483        }
484        if self.persisted_queries.mode == PersistedQueryMode::Automatic
485            && self.persisted_queries.cache_capacity == 0
486        {
487            return Err(ConfigError(
488                "graphql.persisted_queries.cache_capacity must be at least 1 in automatic mode"
489                    .into(),
490            ));
491        }
492        Ok(())
493    }
494}
495
496/// A resolved configuration plus the context it was resolved in.
497#[derive(Debug, Clone, PartialEq, Eq)]
498pub struct Resolved {
499    /// The merged, validated section.
500    pub config: GraphqlConfig,
501    /// The canonical active profile (`dev`, `prod`, `test`, …).
502    pub profile: String,
503    /// Directory relative manifest paths resolve against.
504    pub base_dir: PathBuf,
505}
506
507impl Resolved {
508    /// A resolution for configuration supplied in code: no files are read,
509    /// but the profile still comes from the environment.
510    #[must_use]
511    pub fn explicit(config: GraphqlConfig) -> Self {
512        let env = autumn_web::dotenv::os_env_with_dotenv().ok();
513        let (profile, base_dir) = env.as_ref().map_or_else(
514            || {
515                let os = autumn_web::config::OsEnv;
516                (resolve_active_profile(&os).1, config_base_dir(&os))
517            },
518            |env| (resolve_active_profile(env).1, config_base_dir(env)),
519        );
520        Self {
521            config,
522            profile,
523            base_dir,
524        }
525    }
526
527    /// `dev` and `test` get developer-friendly `auto` defaults
528    /// (introspection on, errors unmasked); every other profile is treated
529    /// as production.
530    #[must_use]
531    pub fn is_development(&self) -> bool {
532        matches!(self.profile.as_str(), "dev" | "test")
533    }
534}
535
536/// Apply `AUTUMN_GRAPHQL__<PATH>` overrides for every leaf key the section
537/// knows, e.g. `AUTUMN_GRAPHQL__LIMITS__MAX_DEPTH=10`.
538///
539/// Values are read as TOML literals (`true`, `10`, `"x"`), falling back to a
540/// bare string. An override that does not type-check is logged and ignored,
541/// matching core: a malformed environment variable must not take down a
542/// process that would boot on its file configuration.
543fn apply_env_overrides(section: &mut toml::Value, config: &mut GraphqlConfig, env: &dyn Env) {
544    let Ok(defaults) = toml::Value::try_from(GraphqlConfig::default()) else {
545        return;
546    };
547    let mut leaves = Vec::new();
548    collect_leaves(&defaults, &mut Vec::new(), &mut leaves);
549    for path in leaves {
550        let key = format!(
551            "{ENV_PREFIX}{}",
552            path.iter()
553                .map(|segment| segment.to_ascii_uppercase())
554                .collect::<Vec<_>>()
555                .join("__")
556        );
557        let Some(raw) = env_trimmed(env, &key) else {
558            continue;
559        };
560        let value = parse_env_value(&raw);
561        let mut candidate = section.clone();
562        set_path(&mut candidate, &path, value);
563        match GraphqlConfig::from_section(Some(&candidate)) {
564            Ok(parsed) => {
565                *section = candidate;
566                *config = parsed;
567            }
568            Err(error) => tracing::warn!(
569                variable = %key,
570                %error,
571                "ignoring an [graphql] environment override that does not type-check"
572            ),
573        }
574    }
575}
576
577fn parse_env_value(raw: &str) -> toml::Value {
578    toml::from_str::<toml::Table>(&format!("v = {raw}"))
579        .ok()
580        .and_then(|mut table| table.remove("v"))
581        .unwrap_or_else(|| toml::Value::String(raw.to_owned()))
582}
583
584fn collect_leaves(value: &toml::Value, prefix: &mut Vec<String>, out: &mut Vec<Vec<String>>) {
585    if let toml::Value::Table(table) = value {
586        for (key, child) in table {
587            prefix.push(key.clone());
588            collect_leaves(child, prefix, out);
589            prefix.pop();
590        }
591    } else {
592        out.push(prefix.clone());
593    }
594}
595
596fn set_path(root: &mut toml::Value, path: &[String], value: toml::Value) {
597    let Some((last, parents)) = path.split_last() else {
598        return;
599    };
600    let mut cursor = root;
601    for segment in parents {
602        let Some(table) = cursor.as_table_mut() else {
603            return;
604        };
605        cursor = table
606            .entry(segment.clone())
607            .or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
608    }
609    if let Some(table) = cursor.as_table_mut() {
610        table.insert(last.clone(), value);
611    }
612}
613
614/// Non-blank env value (a blank value reads as unset, as core treats it).
615fn env_trimmed(env: &dyn Env, key: &str) -> Option<String> {
616    env.var(key)
617        .ok()
618        .map(|value| value.trim().to_owned())
619        .filter(|value| !value.is_empty())
620}
621
622/// The `(selected spelling, canonical name)` of the active profile.
623///
624/// Mirrors core's resolution: `AUTUMN_ENV` → `AUTUMN_PROFILE` → `--profile`
625/// → `AUTUMN_IS_DEBUG=0` ⇒ `prod` → `dev`.
626fn resolve_active_profile(env: &dyn Env) -> (String, String) {
627    let selected = resolve_profile_input(env);
628    let canonical =
629        autumn_web::config::normalize_profile_name(&selected).unwrap_or_else(|| "dev".to_owned());
630    (selected, canonical)
631}
632
633fn resolve_profile_input(env: &dyn Env) -> String {
634    if let Some(value) = env_trimmed(env, "AUTUMN_ENV") {
635        return value;
636    }
637    if let Some(value) = env_trimmed(env, "AUTUMN_PROFILE") {
638        return value;
639    }
640    let args: Vec<String> = std::env::args().collect();
641    for (index, arg) in args.iter().enumerate() {
642        if arg == "--profile"
643            && let Some(profile) = args.get(index.saturating_add(1))
644            && !profile.trim().is_empty()
645        {
646            return profile.trim().to_owned();
647        }
648        if let Some(profile) = arg.strip_prefix("--profile=")
649            && !profile.trim().is_empty()
650        {
651            return profile.trim().to_owned();
652        }
653    }
654    if env_trimmed(env, "AUTUMN_IS_DEBUG").as_deref() == Some("0") {
655        return "prod".to_owned();
656    }
657    "dev".to_owned()
658}
659
660/// Resolve one config filename the way core does: `AUTUMN_MANIFEST_DIR` when
661/// the file is there, else relative to the working directory.
662fn find_config_file(filename: &str, env: &dyn Env) -> PathBuf {
663    if let Some(dir) = env_trimmed(env, "AUTUMN_MANIFEST_DIR") {
664        let candidate = PathBuf::from(dir).join(filename);
665        if candidate.exists() {
666            return candidate;
667        }
668    }
669    PathBuf::from(filename)
670}
671
672/// Where relative paths inside the section (the manifest) resolve from: the
673/// directory holding `autumn.toml`.
674fn config_base_dir(env: &dyn Env) -> PathBuf {
675    find_config_file("autumn.toml", env)
676        .parent()
677        .map(Path::to_path_buf)
678        .filter(|dir| !dir.as_os_str().is_empty())
679        .unwrap_or_else(|| PathBuf::from("."))
680}
681
682fn read_optional_toml(path: &Path) -> Result<Option<toml::Value>, ConfigError> {
683    match std::fs::read_to_string(path) {
684        Ok(contents) => {
685            let table = toml::from_str::<toml::Table>(&contents)
686                .map_err(|e| ConfigError(format!("cannot parse {}: {e}", path.display())))?;
687            Ok(Some(toml::Value::Table(table)))
688        }
689        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
690        Err(error) => Err(ConfigError(format!(
691            "cannot read {}: {error}",
692            path.display()
693        ))),
694    }
695}
696
697fn profile_inline_lookup_names(canonical: &str) -> Vec<&str> {
698    match canonical {
699        "prod" => vec!["production", "prod"],
700        "dev" => vec!["development", "dev"],
701        other => vec![other],
702    }
703}
704
705fn profile_section(base: &toml::Value, profile: &str) -> Option<toml::Value> {
706    base.get("profile")
707        .and_then(toml::Value::as_table)
708        .and_then(|profiles| profiles.get(profile))
709        .and_then(toml::Value::as_table)
710        .map(|table| toml::Value::Table(table.clone()))
711}
712
713/// Deep-merge `overlay` into `base`: tables merge recursively, everything
714/// else replaces. Bounded like core's own merge.
715fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
716    deep_merge_at(base, overlay, 0);
717}
718
719fn deep_merge_at(base: &mut toml::Value, overlay: toml::Value, depth: usize) {
720    const MAX_MERGE_DEPTH: usize = 16;
721    if depth > MAX_MERGE_DEPTH {
722        return;
723    }
724    let toml::Value::Table(overlay_table) = overlay else {
725        return;
726    };
727    let Some(base_table) = base.as_table_mut() else {
728        return;
729    };
730    for (key, overlay_value) in overlay_table {
731        let recurse =
732            overlay_value.is_table() && base_table.get(&key).is_some_and(toml::Value::is_table);
733        if recurse {
734            if let Some(base_value) = base_table.get_mut(&key) {
735                deep_merge_at(base_value, overlay_value, depth.saturating_add(1));
736            }
737        } else {
738            base_table.insert(key, overlay_value);
739        }
740    }
741}
742
743impl fmt::Display for Toggle {
744    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
745        f.write_str(match self {
746            Self::Auto => "auto",
747            Self::On => "true",
748            Self::Off => "false",
749        })
750    }
751}
752
753#[cfg(test)]
754#[allow(clippy::unwrap_used, clippy::expect_used)]
755mod tests {
756    use autumn_web::config::MockEnv;
757
758    use super::*;
759
760    fn temp_dir(name: &str) -> PathBuf {
761        let dir = std::env::temp_dir().join(format!(
762            "autumn-graphql-config-{name}-{}",
763            std::process::id()
764        ));
765        let _ = std::fs::remove_dir_all(&dir);
766        std::fs::create_dir_all(&dir).unwrap();
767        dir
768    }
769
770    #[test]
771    fn defaults_are_production_safe() {
772        let config = GraphqlConfig::default();
773        assert!(config.enabled);
774        assert_eq!(config.path, "/graphql");
775        assert_eq!(config.introspection, Toggle::Auto);
776        assert!(!config.batching.enabled);
777        assert!(!config.uploads.enabled);
778        assert!(config.csrf_prevention);
779        assert_eq!(config.limits.max_depth, 15);
780        config.validate().unwrap();
781    }
782
783    #[test]
784    fn a_missing_section_is_the_defaults() {
785        assert_eq!(
786            GraphqlConfig::from_toml_str("[server]\nport = 3000\n", "graphql").unwrap(),
787            GraphqlConfig::default()
788        );
789        assert_eq!(
790            GraphqlConfig::from_toml_str("", "graphql").unwrap(),
791            GraphqlConfig::default()
792        );
793    }
794
795    #[test]
796    fn a_partial_section_keeps_the_other_defaults() {
797        let config = GraphqlConfig::from_toml_str(
798            "[graphql]\nintrospection = false\n[graphql.limits]\nmax_depth = 7\n",
799            "graphql",
800        )
801        .unwrap();
802        assert_eq!(config.introspection, Toggle::Off);
803        assert_eq!(config.limits.max_depth, 7);
804        assert_eq!(config.limits.max_aliases, 30);
805        assert_eq!(config.path, "/graphql");
806    }
807
808    #[test]
809    fn typos_and_wrong_types_are_errors_not_defaults() {
810        assert!(
811            GraphqlConfig::from_toml_str("[graphql]\nintrospectoin = false\n", "graphql").is_err()
812        );
813        assert!(
814            GraphqlConfig::from_toml_str("[graphql]\nintrospection = \"flase\"\n", "graphql")
815                .is_err()
816        );
817        assert!(
818            GraphqlConfig::from_toml_str("[graphql.limits]\nmax_depth = \"x\"\n", "graphql")
819                .is_err()
820        );
821        assert!(GraphqlConfig::from_toml_str("[graphql\n", "graphql").is_err());
822    }
823
824    #[test]
825    fn toggles_accept_booleans_and_words() {
826        for (text, expected) in [
827            ("true", Toggle::On),
828            ("false", Toggle::Off),
829            ("\"auto\"", Toggle::Auto),
830            ("\"on\"", Toggle::On),
831            ("\"disabled\"", Toggle::Off),
832        ] {
833            let config =
834                GraphqlConfig::from_toml_str(&format!("[graphql]\nsdl = {text}\n"), "graphql")
835                    .unwrap();
836            assert_eq!(config.sdl, expected, "{text}");
837        }
838        assert!(Toggle::Auto.resolve(true));
839        assert!(!Toggle::Auto.resolve(false));
840        assert!(Toggle::On.resolve(false));
841        assert!(!Toggle::Off.resolve(true));
842    }
843
844    #[test]
845    fn invalid_values_are_rejected() {
846        for bad in [
847            "[graphql]\npath = \"graphql\"\n",
848            "[graphql]\npath = \"/\"\n",
849            "[graphql]\npath = \"/graphql/\"\n",
850            "[graphql]\npath = \"/{id}\"\n",
851            "[graphql]\nmax_body_bytes = 0\n",
852            "[graphql.batching]\nenabled = true\nmax_operations = 0\n",
853            "[graphql.uploads]\nenabled = true\nmax_files = 0\n",
854            "[graphql.persisted_queries]\nmode = \"automatic\"\ncache_capacity = 0\n",
855            "[graphql.persisted_queries]\nmode = \"sometimes\"\n",
856        ] {
857            assert!(
858                GraphqlConfig::from_toml_str(bad, "graphql").is_err(),
859                "should reject: {bad}"
860            );
861        }
862    }
863
864    #[test]
865    fn profile_layers_and_env_override_in_order() {
866        let dir = temp_dir("layers");
867        std::fs::write(
868            dir.join("autumn.toml"),
869            "[graphql]\npath = \"/api/graphql\"\nintrospection = true\ntimeout_ms = 5\n\
870             [graphql.limits]\nmax_depth = 20\n\
871             [profile.prod.graphql]\nintrospection = false\n",
872        )
873        .unwrap();
874        std::fs::write(
875            dir.join("autumn-prod.toml"),
876            "[graphql.limits]\nmax_depth = 12\n",
877        )
878        .unwrap();
879
880        let env = MockEnv::new()
881            .with("AUTUMN_MANIFEST_DIR", dir.to_str().unwrap())
882            .with("AUTUMN_ENV", "prod")
883            .with("AUTUMN_GRAPHQL__TIMEOUT_MS", "250")
884            .with("AUTUMN_GRAPHQL__LIMITS__MAX_ALIASES", "3")
885            .with("AUTUMN_GRAPHQL__PERSISTED_QUERIES__MODE", "trusted");
886        let resolved = GraphqlConfig::resolve_with_env("graphql", &env).unwrap();
887        assert_eq!(resolved.profile, "prod");
888        assert!(!resolved.is_development());
889        let config = resolved.config;
890        assert_eq!(config.path, "/api/graphql", "base layer");
891        assert_eq!(config.introspection, Toggle::Off, "inline profile layer");
892        assert_eq!(config.limits.max_depth, 12, "profile file layer");
893        assert_eq!(config.timeout_ms, 250, "env layer");
894        assert_eq!(config.limits.max_aliases, 3, "nested env layer");
895        assert_eq!(config.persisted_queries.mode, PersistedQueryMode::Trusted);
896        assert_eq!(resolved.base_dir, dir);
897
898        let dev = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.to_str().unwrap());
899        let resolved = GraphqlConfig::resolve_with_env("graphql", &dev).unwrap();
900        assert_eq!(resolved.profile, "dev");
901        assert!(resolved.is_development());
902        assert_eq!(resolved.config.introspection, Toggle::On);
903        assert_eq!(resolved.config.limits.max_depth, 20);
904        let _ = std::fs::remove_dir_all(dir);
905    }
906
907    #[test]
908    fn a_malformed_env_override_is_ignored() {
909        let env = MockEnv::new()
910            .with("AUTUMN_MANIFEST_DIR", "/nonexistent-autumn-graphql")
911            .with("AUTUMN_GRAPHQL__LIMITS__MAX_DEPTH", "deep")
912            .with("AUTUMN_GRAPHQL__INTROSPECTION", "auto")
913            .with("AUTUMN_GRAPHQL__ENABLED", "false");
914        let config = GraphqlConfig::resolve_with_env("graphql", &env)
915            .unwrap()
916            .config;
917        assert_eq!(config.limits.max_depth, 15);
918        assert_eq!(config.introspection, Toggle::Auto);
919        assert!(!config.enabled);
920    }
921
922    #[test]
923    fn a_custom_section_name_is_read() {
924        let config = GraphqlConfig::from_toml_str(
925            "[graphql]\npath = \"/a\"\n[graphql_admin]\npath = \"/admin/graphql\"\n",
926            "graphql_admin",
927        )
928        .unwrap();
929        assert_eq!(config.path, "/admin/graphql");
930    }
931
932    #[test]
933    fn an_unreadable_file_is_an_error() {
934        let dir = temp_dir("unparseable");
935        std::fs::write(dir.join("autumn.toml"), "[graphql\n").unwrap();
936        let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.to_str().unwrap());
937        assert!(GraphqlConfig::resolve_with_env("graphql", &env).is_err());
938        let _ = std::fs::remove_dir_all(dir);
939    }
940}