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}