Skip to main content

apollo_configuration/
lib.rs

1//! A typed, friendly configuration system for Apollo products.
2//!
3//! # The `#[configuration]` attribute
4//!
5//! apollo-configuration's `#[configuration]` attribute provides:
6//! - Parsing of YAML files with friendly error reporting
7//! - JSON schema generation
8//! - Validation of user input according to that JSON schema
9//! - Defaulting of field values
10//! - Expansion of environment variables
11//!
12//! ```rust
13//! use apollo_configuration::configuration;
14//! use apollo_configuration::parse_yaml;
15//!
16//! #[configuration]
17//! struct CsrfConfig {
18//!     #[config(default = true)]
19//!     enabled: bool,
20//!     #[config(default = default_required_headers())]
21//!     required_headers: Vec<String>,
22//! }
23//!
24//! fn default_required_headers() -> Vec<String> {
25//!     vec![
26//!         "x-apollo-operation-name".to_string(),
27//!         "apollo-require-preflight".to_string(),
28//!     ]
29//! }
30//! ```
31//!
32//! ## JSON Schema Generation
33//! Use [`export_json_schema`] to generate a JSON schema for a [`Configuration`] struct.
34//!
35//! ## YAML Parsing
36//! Use [`parse_yaml`] to parse and validate YAML text into a [`Configuration`] struct.
37//!
38//! ## Validation
39//! Configuration structs are validated using JSON Schema, and each field is validated according to
40//! its own [validation rules](#field-validation). It's common for configuration structures to
41//! require "cross-cutting" validation: for example, disallowing two features that cannot be used
42//! together. To do things like this, you can add an additional custom validation function using
43//! the `#[configuration(validate)]` attribute.
44//!
45//! ```rust
46//! use apollo_configuration::configuration;
47//! use apollo_configuration::ErrorCollector;
48//!
49//! #[configuration]
50//! struct HomepageConfig {
51//!     enabled: bool,
52//!     graph_ref: Option<String>,
53//! }
54//!
55//! #[configuration]
56//! struct ApolloSandboxConfig {
57//!     enabled: bool,
58//! }
59//!
60//! #[configuration(validate = validate_config)]
61//! struct Config {
62//!     homepage: HomepageConfig,
63//!     sandbox: ApolloSandboxConfig,
64//! }
65//!
66//! fn validate_config(config: &Config, mut errors: ErrorCollector<'_>) {
67//!     if config.homepage.enabled && config.sandbox.enabled {
68//!         errors.nest("sandbox")
69//!             .report_simple("sandbox can only be enabled if homepage is disabled");
70//!     }
71//! }
72//! ```
73//!
74//! # The `#[config]` attribute
75//! The `#[config]` attribute can be used on the structure's fields to change how fields are
76//! parsed, validated, and displayed.
77//!
78//! ## Optional/Required fields
79//! All fields are assumed to be optional by default. The type's [`Default`] impl is used when a
80//! field is absent during deserialization. If a field is required, mark it with
81//! `#[config(required)]`. If a configuration structure contains any required fields, it will not
82//! implement [`Default`].
83//!
84//! If a field's type does not have a [`Default`] impl, you can provide a default value:
85//! ```rust
86//! # use std::num::NonZeroUsize;
87//! # use apollo_configuration::configuration;
88//! #
89//! #[configuration]
90//! struct CacheConfig {
91//!     #[config(default = NonZeroUsize::new(500).unwrap())]
92//!     cache_size: NonZeroUsize,
93//! }
94//! ```
95//!
96//! ## Field validation
97//! apollo-configuration supports schema-level validation and fully custom validation.
98//!
99//! Schema-level validation works by using one of the below sub-attributes inside the `#[config]`
100//! attribute:
101//!
102//! - `range(min = 0, max = 1024)` - Restrict the value of an integer field to the given range.
103//! - `length(min = 0, max = 256)` - Restrict the length of a string or array field to the given
104//!   range.
105//! - `length(exact = 10)` - Require a string or array field to have the given length exactly.
106//! - `pattern(r"^\d+$")` - Require a string field to match the given regular expression.
107//!
108//! Schema-level validation is also reflected in the generated JSON schema.
109//!
110//! Custom validation works by specifying a validation function in the `#[config(validate)]`
111//! attribute:
112//!
113//! ```rust
114//! use apollo_configuration::configuration;
115//! use apollo_configuration::ErrorCollector;
116//!
117//! #[configuration]
118//! struct Config {
119//!     #[config(validate = validate_div_by_3)]
120//!     magic_number: usize,
121//! }
122//!
123//! fn validate_div_by_3(magic_number: &usize, mut errors: ErrorCollector<'_>) {
124//!     if !magic_number.is_multiple_of(3) {
125//!         errors.report_simple("magic number must be divisible by 3");
126//!     }
127//! }
128//! ```
129//!
130//! Custom validation is not reflected in the JSON schema. Generally, schema-level validation
131//! should be preferred. However, custom validation supports rich diagnostics using
132//! [`ErrorCollector::report`], so it can still be a good choice if schema-level validation gives poor
133//! errors.
134//!
135//! Custom validation can be disabled using the `#[config(skip_validate)]` attribute. This
136//! can be necessary when using external types that do not implement `Validate`:
137//!
138//! ```rust
139//! use apollo_configuration::configuration;
140//!
141//! #[configuration]
142//! struct Config {
143//!     #[config(skip_validate, default = tinystr::TinyAsciiStr::try_from_utf8(b"unnamed").unwrap())]
144//!     #[schemars(with = "String")]
145//!     name: tinystr::TinyAsciiStr<16>,
146//! }
147//! ```
148//!
149//! # Expansion
150//!
151//! Your users can write `${env.VAR_NAME}` syntax inside YAML values to insert the value of an
152//! environment variable into the configuration. This can be used to include more dynamic values
153//! or secrets without having to write them into the configuration file.
154//!
155//! It is an error to reference an environment variable that is not defined. The bash-style
156//! defaulting syntax can be used to handle optional environment variables:
157//! `${env.VAR_NAME:-default}`.
158//!
159//! If your configuration structure contains secrets, API keys, passwords, or sensitive URLs,
160//! remember to use a redaction helper such as [`apollo-redaction`].
161//!
162//! [`apollo-redaction`]: https://docs.rs/apollo-redaction
163//!
164//! # Types for use as configuration values
165//!
166//! Primitive Rust types and several ecosystem types are supported as configuration values.
167//! The [`types`] module provides wrappers for common external types that can be
168//! used in configuration.
169//!
170//! Available newtypes:
171//! - [`types::Duration`] - human-readable format (`30s`, `5m`, `1h30m`, `100ms`)
172//! - [`types::IpAddr`], [`types::Ipv4Addr`], [`types::Ipv6Addr`]
173//! - [`types::SocketAddr`], [`types::SocketAddrV4`], [`types::SocketAddrV6`]
174//! - [`types::Url`]
175//! - [`types::Method`], [`types::Uri`], [`types::HeaderName`], [`types::HeaderValue`]
176//! - [`types::MetadataKey`], [`types::MetadataValue`] - gRPC metadata types
177//! - [`types::Regex`]
178//!
179//! To enable these types add the appropriate feature to the apollo-configuration dependency:
180//! `duration`, `grpc`, `http`, `regex`, `url`
181//!
182//! All newtypes support wrapping with [`apollo_redaction::Redacted`] for sensitive values
183//! like API keys or passwords:
184//!
185//! ```rust
186//! use apollo_configuration::configuration;
187//! use apollo_redaction::Redacted;
188//!
189//! #[configuration]
190//! struct ApiConfig {
191//!     #[config(required)]
192//!     api_key: Redacted<String>,
193//! }
194//!
195//! let config: ApiConfig = apollo_configuration::parse_yaml(
196//!     "api_key: secret-key-123",
197//!     &Default::default(),
198//! ).unwrap();
199//!
200//! // The value is accessible but protected from accidental logging
201//! assert_eq!(config.api_key.unredact(), "secret-key-123");
202//! assert_eq!(config.api_key.to_string(), "[REDACTED]");
203//! ```
204//!
205//! See the [`apollo-redaction`] crate for details.
206//!
207//! ## Other types
208//!
209//! To use a type as a configuration value, it must implement these traits:
210//! - [`Debug`]
211//! - [`schemars::JsonSchema`]
212//! - [`serde::Deserialize`]
213//!
214//! Where it makes sense, types used in configuration values should also implement [`Clone`],
215//! [`Default`], and [`serde::Serialize`].
216//!
217//! # Enum configuration
218//!
219//! Enums can use the `#[configuration]` attribute. This is useful for configuration options
220//! where the user selects between different modes or backends.
221//!
222//! ```rust
223//! use apollo_configuration::configuration;
224//!
225//! #[configuration]
226//! enum Endpoint {
227//!     Http { url: String, timeout: u64 },
228//!     Grpc { host: String, port: u16 },
229//! }
230//! ```
231//!
232//! ## Default variant
233//!
234//! Mark a variant with `#[config(default)]` to make the enum implement [`Default`]. When parsing
235//! from YAML, omitted enum fields will use this default:
236//!
237//! ```rust
238//! use apollo_configuration::configuration;
239//! use apollo_configuration::parse_yaml;
240//!
241//! #[configuration]
242//! #[derive(PartialEq, Eq)]
243//! enum LogLevel {
244//!     Debug,
245//!     #[config(default)]
246//!     Info,
247//!     Warn,
248//!     Error,
249//! }
250//!
251//! #[configuration]
252//! #[derive(PartialEq, Eq)]
253//! struct Config {
254//!     log_level: LogLevel,
255//! }
256//!
257//! let config: Config = parse_yaml(r#"log_level: debug"#, &Default::default()).unwrap();
258//! assert_eq!(config.log_level, LogLevel::Debug);
259//!
260//! let config: Config = parse_yaml(r#"{}"#, &Default::default()).unwrap();
261//! assert_eq!(config.log_level, LogLevel::Info);
262//! ```
263//!
264//! ## Unit variant fields
265//!
266//! Unit variant enums can be parsed directly from string values in YAML. The `#[configuration]`
267//! attribute applies `#[serde(rename_all = "snake_case")]` automatically:
268//!
269//! ```rust
270//! use apollo_configuration::configuration;
271//!
272//! #[configuration]
273//! #[derive(PartialEq, Eq)]
274//! enum EnabledMode {
275//!     Measure,
276//!     #[config(default)]
277//!     Enforce,
278//! }
279//!
280//! #[configuration]
281//! struct DemandControlConfig {
282//!     enabled: bool,
283//!     mode: EnabledMode,
284//! }
285//!
286//! let config: DemandControlConfig = apollo_configuration::parse_yaml(r#"
287//!     enabled: true
288//!     mode: measure
289//! "#, &Default::default()).unwrap();
290//! assert_eq!(config.mode, EnabledMode::Measure);
291//! ```
292//!
293//! ## Struct variant fields
294//!
295//! Struct variant fields support the same `#[config(...)]` attributes as struct fields:
296//!
297//! ```rust
298//! use apollo_configuration::configuration;
299//!
300//! #[configuration]
301//! enum Endpoint {
302//!     #[config(default)]
303//!     Http {
304//!         #[config(default = "http://localhost".to_string())]
305//!         url: String,
306//!         #[config(default = 30)]
307//!         timeout: u64,
308//!     },
309//!     Grpc {
310//!         #[config(required)]
311//!         host: String,
312//!         #[config(default = 9090)]
313//!         port: u16,
314//!     },
315//! }
316//! ```
317//!
318//! ## Tuple variant fields
319//!
320//! Tuple variants wrap a single inner type. The `#[config(...)]` attributes apply to the inner
321//! type:
322//!
323//! ```rust
324//! use apollo_configuration::configuration;
325//!
326//! #[configuration]
327//! struct HttpConfig {
328//!     #[config(default = "http://localhost".to_string())]
329//!     url: String,
330//! }
331//!
332//! #[configuration]
333//! enum Endpoint {
334//!     #[config(default)]
335//!     Http(HttpConfig),
336//!     Custom(String),
337//! }
338//! ```
339//!
340use std::sync::Arc;
341
342use jsonschema::error::ValidationErrorKind;
343use jsonschema::paths::LazyLocation;
344use jsonschema::paths::Location;
345use miette::Diagnostic;
346use miette::SourceSpan;
347use saphyr::LoadableYamlNode as _;
348use saphyr::MarkedYamlOwned;
349use schemars::JsonSchema;
350use schemars::SchemaGenerator;
351use serde::Deserialize;
352
353mod errors;
354pub mod expansion;
355mod overridable;
356#[doc(hidden)]
357pub mod private;
358pub mod types;
359mod validate;
360
361pub use apollo_configuration_macros::configuration;
362pub use errors::*;
363pub use overridable::Overridable;
364pub use validate::ErrorCollector;
365pub use validate::Validate;
366pub use validate::validate;
367
368/// Marker trait for configuration structs. See the [`#[configuration]`][configuration] proc macro for
369/// documentation.
370pub trait Configuration: JsonSchema + for<'de> Deserialize<'de> + Validate {}
371
372impl<T> Configuration for Box<T> where T: Configuration {}
373// XXX(@goto-bus-stop): make sure we can do this?
374// impl <T> Configuration for Arc<T> where T: Configuration {}
375
376/// Return a JSON schema for the given configuration structure.
377///
378/// # Panics
379/// Panics if the generated schema does not match the meta-schema.
380/// This can happen only if [`schemars::JsonSchema`] was implemented incorrectly for a type.
381pub fn export_json_schema<T: Configuration>() -> serde_json::Value {
382    let schema = SchemaGenerator::default().root_schema_for::<T>().to_value();
383
384    jsonschema::meta::validate(&schema).expect("generated schema is invalid");
385
386    schema
387}
388
389fn to_miette_span(yaml_span: saphyr_parser::Span) -> SourceSpan {
390    let start = yaml_span.start.index();
391    let end = yaml_span.end.index();
392    SourceSpan::new(start.into(), end - start)
393}
394
395fn empty_span() -> SourceSpan {
396    SourceSpan::new(0.into(), 0)
397}
398
399// We have to do multiple things with our YAML values.
400// 1. Validate according to JSON Schema: this requires a dynamic `serde_json::Value`.
401// 2. Report errors with source location. This requires a parse tree with location data
402// 2. Parse into a Rust struct with source locations. This requires serde + a parse tree with
403//    location data
404fn yaml_to_json<'a, 'b>(
405    location_data: &YamlLocationData,
406    location: LazyLocation<'a, 'b>,
407    yaml: serde_yaml::Value,
408) -> Result<serde_json::Value, YamlToJsonError> {
409    let source_code = location_data.source_code.clone();
410    let make_span = || {
411        location_data
412            .resolve_instance_span(&(&location).into())
413            .unwrap_or_else(empty_span)
414    };
415
416    Ok(match yaml {
417        serde_yaml::Value::Null => serde_json::Value::Null,
418        serde_yaml::Value::Bool(value) => serde_json::Value::Bool(value),
419        serde_yaml::Value::Number(value) => {
420            serde_json::Value::Number(if let Some(inner) = value.as_i64() {
421                inner.into()
422            } else if let Some(inner) = value.as_f64() {
423                serde_json::Number::from_f64(inner).ok_or_else(|| {
424                    YamlToJsonError::InvalidNumberError {
425                        source_code: source_code.clone(),
426                        label: make_span(),
427                    }
428                })?
429            } else {
430                return Err(YamlToJsonError::InvalidNumberError {
431                    source_code: source_code.clone(),
432                    label: make_span(),
433                });
434            })
435        }
436        serde_yaml::Value::String(value) => serde_json::Value::String(value),
437        serde_yaml::Value::Tagged(tagged) => yaml_to_json(location_data, location, tagged.value)?,
438        serde_yaml::Value::Mapping(value) => serde_json::Value::Object(
439            value
440                .into_iter()
441                .map(|(key, value)| {
442                    let key = key
443                        .as_str()
444                        .ok_or_else(|| YamlToJsonError::InvalidMappingError {
445                            source_code: source_code.clone(),
446                            label: make_span(),
447                        })?;
448                    Ok::<_, YamlToJsonError>((
449                        key.to_string(),
450                        yaml_to_json(location_data, location.push(key), value)?,
451                    ))
452                })
453                .collect::<Result<_, _>>()?,
454        ),
455        serde_yaml::Value::Sequence(value) => serde_json::Value::Array(
456            value
457                .into_iter()
458                .enumerate()
459                .map(|(index, value)| yaml_to_json(location_data, location.push(index), value))
460                .collect::<Result<_, _>>()?,
461        ),
462    })
463}
464
465#[derive(Debug, PartialEq)]
466struct YamlLocationData {
467    source_code: Arc<str>,
468    marked_yaml: MarkedYamlOwned,
469}
470
471impl YamlLocationData {
472    fn parse(text: &str) -> Result<Self, errors::ConfigError> {
473        let source_code = Arc::from(text);
474        let mut loaded = MarkedYamlOwned::load_from_str(&source_code)?;
475
476        match loaded.len() {
477            0 => Ok(Self::empty()),
478            _ => Ok(Self {
479                source_code,
480                marked_yaml: loaded.remove(0),
481            }),
482        }
483    }
484
485    fn empty() -> Self {
486        Self {
487            source_code: Arc::from(""),
488            marked_yaml: MarkedYamlOwned::from_bare_yaml(saphyr::Yaml::BadValue),
489        }
490    }
491
492    /// Create a [`miette::SourceSpan`] for the YAML element pointed to by the given JSON pointer.
493    fn resolve_instance_span(&self, location: &Location) -> Option<miette::SourceSpan> {
494        Some(to_miette_span(self.resolve_element(location)?.span))
495    }
496
497    /// Navigate to the YAML element at the given location.
498    fn resolve_element(&self, location: &Location) -> Option<&MarkedYamlOwned> {
499        let mut element = &self.marked_yaml;
500        for segment in location.iter() {
501            match segment {
502                jsonschema::paths::LocationSegment::Property(name) => {
503                    element = element.data.as_mapping_get(&name)?;
504                }
505                jsonschema::paths::LocationSegment::Index(index) => {
506                    element = element.data.as_sequence_get(index)?;
507                }
508            }
509        }
510        Some(element)
511    }
512
513    /// Create a [`miette::SourceSpan`] for a mapping key at the given location.
514    ///
515    /// This is useful for errors about unexpected properties, where we want to highlight
516    /// the key name rather than the value.
517    fn resolve_key_span(&self, location: &Location, key: &str) -> Option<miette::SourceSpan> {
518        let element = self.resolve_element(location)?;
519        let mapping = element.data.as_mapping()?;
520        for (k, _v) in mapping {
521            if k.data.as_str() == Some(key) {
522                return Some(to_miette_span(k.span));
523            }
524        }
525        None
526    }
527
528    fn resolve_error_path(&self, path: &serde_path_to_error::Path) -> Option<miette::SourceSpan> {
529        let mut element = &self.marked_yaml;
530        for segment in path.iter() {
531            match segment {
532                serde_path_to_error::Segment::Unknown => return None,
533                serde_path_to_error::Segment::Seq { index } => {
534                    element = element.data.as_sequence_get(*index)?;
535                }
536                serde_path_to_error::Segment::Map { key } => {
537                    element = element.data.as_mapping_get(key)?;
538                }
539                serde_path_to_error::Segment::Enum { variant } => {
540                    element = element.data.as_mapping_get(variant)?;
541                }
542            }
543        }
544
545        Some(to_miette_span(element.span))
546    }
547}
548
549/// Options for parsing configuration from YAML.
550#[derive(Default)]
551pub struct ParseYamlOptions {
552    variables: Option<Box<dyn expansion::VariableProvider>>,
553}
554
555impl ParseYamlOptions {
556    /// Configure the variable provider for expansion.
557    ///
558    /// ## Default
559    /// By default, the [`EnvVariables`] provider is used, using environment variables available to
560    /// the process.
561    ///
562    /// To disable variable expansion outright, provide the unit type:
563    /// ```rust,ignore
564    /// let options = options.variables(());
565    /// ```
566    ///
567    /// [`EnvVariables`]: expansion::EnvVariables
568    pub fn variables(mut self, variables: impl expansion::VariableProvider + 'static) -> Self {
569        self.variables = Some(Box::new(variables));
570        self
571    }
572
573    pub fn parse<T: Configuration>(&self, text: &str) -> Result<T, errors::ConfigError> {
574        parse_yaml(text, self)
575    }
576}
577
578/// Resolve the source span for a validation error.
579///
580/// For most errors, this returns the span of the value that failed validation.
581/// For `AdditionalProperties` errors, this returns the span of the unexpected key name,
582/// which provides better error highlighting.
583fn resolve_schema_error_span(
584    location_data: &YamlLocationData,
585    err: &jsonschema::ValidationError<'_>,
586) -> SourceSpan {
587    let fallback = || SourceSpan::new(0.into(), 0);
588
589    // For AdditionalProperties errors, highlight the unexpected key name
590    if let ValidationErrorKind::AdditionalProperties { unexpected } = err.kind()
591        && let Some(first_unexpected) = unexpected.first()
592        && let Some(span) = location_data.resolve_key_span(err.instance_path(), first_unexpected)
593    {
594        return span;
595    }
596
597    // TODO(@goto-bus-stop): We can further special case;
598    // in particular for `anyOf` constraints, we can pick the most suitable error to
599    // display based on its inner errors (eg. the inner error that is NOT `additionalProperties`)
600
601    // Default: highlight the value at the instance path
602    location_data
603        .resolve_instance_span(err.instance_path())
604        .unwrap_or_else(fallback)
605}
606
607pub fn parse_yaml<T: Configuration>(
608    text: &str,
609    options: &ParseYamlOptions,
610) -> Result<T, errors::ConfigError> {
611    let location_data = YamlLocationData::parse(text)?;
612
613    let instance = serde_yaml::from_str(text)?;
614    let mut instance: serde_json::Value = if matches!(instance, serde_yaml::Value::Null) {
615        serde_json::Value::Object(Default::default())
616    } else {
617        yaml_to_json(&location_data, LazyLocation::new(), instance)?
618    };
619
620    if let Some(variables) = options.variables.as_deref() {
621        expansion::expand(&mut instance, variables, Some(&location_data))?;
622    }
623
624    let schema = export_json_schema::<T>();
625    let errors = jsonschema::validator_for(&schema)
626        .expect("schema is known to be valid")
627        .iter_errors(&instance)
628        .map(|err| {
629            Box::new(errors::ValidationError {
630                label: resolve_schema_error_span(&location_data, &err),
631                message: err.to_string(),
632            }) as Box<dyn Diagnostic + Send + Sync + 'static>
633        })
634        .collect::<Vec<_>>();
635    if !errors.is_empty() {
636        return Err(ConfigError::ValidationError(ValidationErrors {
637            source_code: location_data.source_code.clone(),
638            errors,
639        }));
640    }
641
642    // XXX(@goto-bus-stop): it would be nice to accumulate errors here, for example with https://docs.rs/serdev
643    let instance: T = match serde_path_to_error::deserialize(&instance) {
644        Ok(instance) => instance,
645        Err(err) => {
646            return Err(ConfigError::InvalidValue {
647                source_code: location_data.source_code.clone(),
648                label: location_data.resolve_error_path(err.path()),
649                error: err.into_inner(),
650            });
651        }
652    };
653
654    let mut errors = vec![];
655    instance.validate(ErrorCollector::new(&location_data, &mut errors));
656    if !errors.is_empty() {
657        return Err(ConfigError::ValidationError(ValidationErrors {
658            source_code: location_data.source_code.clone(),
659            errors,
660        }));
661    }
662
663    Ok(instance)
664}
665
666#[cfg(test)]
667mod tests {
668    use crate::YamlLocationData;
669    use jsonschema::paths::Location;
670
671    #[test]
672    fn json_schema_instance_path() {
673        let yaml = YamlLocationData::parse(
674            r#"
675            core:
676            - a: { c: "/core/0/a/c" }
677            - b: { c: 2 }
678        "#,
679        )
680        .expect("valid YAML");
681
682        assert_eq!(
683            yaml.resolve_instance_span(&Location::new().join("core").join(1)),
684            Some(miette::SourceSpan::new(71.into(), 20)),
685        );
686        assert_eq!(
687            yaml.resolve_instance_span(&Location::new().join("core").join(1).join("b")),
688            Some(miette::SourceSpan::new(74.into(), 7)),
689        );
690        assert_eq!(
691            yaml.resolve_instance_span(&Location::new().join("core").join(0).join("a").join("c")),
692            Some(miette::SourceSpan::new(41.into(), 14)),
693        );
694        assert_eq!(
695            yaml.resolve_instance_span(&Location::new().join("core").join(2)),
696            None,
697        );
698    }
699
700    #[test]
701    fn empty_yaml_string_empty_yaml_location_data() {
702        assert_eq!(
703            YamlLocationData::parse("").unwrap(),
704            YamlLocationData::empty()
705        );
706    }
707}