1use std::fmt;
33use std::path::{Path, PathBuf};
34
35use autumn_web::config::Env;
36use serde::{Deserialize, Deserializer, Serialize, Serializer};
37
38pub const ENV_PREFIX: &str = "AUTUMN_GRAPHQL__";
40
41pub const DEFAULT_SECTION: &str = "graphql";
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54pub enum Toggle {
55 #[default]
57 Auto,
58 On,
60 Off,
62}
63
64impl Toggle {
65 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum PersistedQueryMode {
118 #[default]
122 Disabled,
123 Automatic,
128 Trusted,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(deny_unknown_fields, default)]
137#[non_exhaustive]
138pub struct BatchingConfig {
139 pub enabled: bool,
142 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(deny_unknown_fields, default)]
162#[non_exhaustive]
163pub struct LimitsConfig {
164 pub max_query_bytes: usize,
166 pub max_nesting: usize,
169 pub max_depth: usize,
172 pub max_aliases: usize,
174 pub max_root_fields: usize,
176 pub max_fields: usize,
179 pub max_directives: usize,
181 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(deny_unknown_fields, default)]
205#[non_exhaustive]
206pub struct SubscriptionsConfig {
207 pub websocket: Toggle,
210 pub sse: Toggle,
214 pub max_connections: usize,
216 pub keepalive_secs: u64,
218 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(deny_unknown_fields, default)]
238#[non_exhaustive]
239pub struct PersistedQueriesConfig {
240 pub mode: PersistedQueryMode,
242 pub cache_capacity: usize,
244 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(deny_unknown_fields, default)]
263#[non_exhaustive]
264pub struct UploadsConfig {
265 pub enabled: bool,
267 pub max_files: usize,
269 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(deny_unknown_fields, default)]
286#[non_exhaustive]
287pub struct GraphqlConfig {
288 pub enabled: bool,
290 pub path: String,
292 pub introspection: Toggle,
295 pub sdl: Toggle,
298 pub mask_unexpected_errors: Toggle,
302 pub allow_get: bool,
305 pub max_body_bytes: usize,
308 pub timeout_ms: u64,
311 pub slow_operation_ms: u64,
314 pub csrf_prevention: bool,
319 pub batching: BatchingConfig,
321 pub limits: LimitsConfig,
323 pub subscriptions: SubscriptionsConfig,
325 pub persisted_queries: PersistedQueriesConfig,
327 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
357#[error("invalid [graphql] configuration: {0}")]
358pub struct ConfigError(pub String);
359
360impl GraphqlConfig {
361 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 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 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(§ion_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 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#[derive(Debug, Clone, PartialEq, Eq)]
498pub struct Resolved {
499 pub config: GraphqlConfig,
501 pub profile: String,
503 pub base_dir: PathBuf,
505}
506
507impl Resolved {
508 #[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 #[must_use]
531 pub fn is_development(&self) -> bool {
532 matches!(self.profile.as_str(), "dev" | "test")
533 }
534}
535
536fn 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
614fn 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
622fn 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
660fn 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
672fn 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
713fn 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}