apollo-configuration 0.4.0

A typed, friendly configuration system for Apollo products.
Documentation
//! Implementation for custom validation rules in configuration.

use apollo_redaction::Redacted;

use crate::ValidationErrors;
use crate::YamlLocationData;
use crate::errors::ValidationError;
use jsonschema::paths::Location;
use jsonschema::paths::LocationSegment;
use miette::Diagnostic;
use std::fmt::Display;
use std::sync::Arc;

/// Error collector for custom validation rules.
///
/// When implementing your own validation rule for an object or list of elements, use the
/// [`ErrorCollector::nest`] method to track the current location for error reporting.
// XXX(@goto-bus-stop): an alternative API could not use the `.nest(property)` style, but have
// several methods that take JSON pointers:
//  - `report_at(JSONPointer, Diagnostic)` (and `report(Diagnostic)`)
//  - `span_at(JSONPointer)` (and `span()`)
//  - `at(JSONPointer)`
// This may be nicer to use, but I think we can start with this more minimal, more manual approach.
pub struct ErrorCollector<'e> {
    location_data: &'e YamlLocationData,
    errors: &'e mut Vec<Box<dyn Diagnostic + Send + Sync + 'static>>,
    path: Location,
}

impl<'e> ErrorCollector<'e> {
    pub(crate) fn new(
        location_data: &'e YamlLocationData,
        errors: &'e mut Vec<Box<dyn Diagnostic + Send + Sync + 'static>>,
    ) -> Self {
        Self {
            location_data,
            errors,
            path: Location::new(),
        }
    }

    /// Produce a validator for an "inner" type. This means a type that's nested in Rust-land, but
    /// not in the source YAML.
    pub fn inner(&mut self) -> ErrorCollector<'_> {
        ErrorCollector {
            location_data: self.location_data,
            errors: self.errors,
            path: self.path.clone(),
        }
    }

    /// Produce a validator for a nested object property or array index.
    pub fn nest<'s, 'a>(
        &'s mut self,
        segment: impl Into<LocationSegment<'a>>,
    ) -> ErrorCollector<'s> {
        ErrorCollector {
            location_data: self.location_data,
            errors: self.errors,
            path: self.path.join(segment),
        }
    }

    /// Return the current location.
    pub fn span(&self) -> miette::SourceSpan {
        self.location_data
            .resolve_instance_span(&self.path)
            .unwrap_or_else(|| miette::SourceSpan::new(0.into(), 0))
    }

    /// Report a validation error at the current location.
    ///
    /// Any [`miette::Diagnostic`] can be used as the error value, but the recommendation for
    /// Apollo products is to use [`apollo_errors`], for its error code support.
    pub fn report(&mut self, diag: impl Diagnostic + Send + Sync + 'static) {
        self.errors.push(Box::new(diag));
    }

    /// Report a validation error at the current location. This is intended for one-off, simple,
    /// single-field errors pointing at the current value.
    ///
    /// We generally recommend you use [`apollo_errors`] for errors, even configuration validation
    /// errors, for example for its error codes.
    pub fn report_simple(&mut self, message: impl Display) {
        self.report(ValidationError {
            label: self.span(),
            message: message.to_string(),
        });
    }

    /// Returns the number of errors already reported.
    ///
    /// This concerns *all* errors, not just errors generated in the current call to
    /// [`Validate::validate`] or its descendents. For example, when validating the second item in
    /// a list, `.len()` will include validation errors from the first item in the list.
    pub fn len(&self) -> usize {
        self.errors.len()
    }

    /// Returns true if any errors have been reported.
    pub fn is_empty(&self) -> bool {
        self.errors.is_empty()
    }
}

/// Validation trait for configuration values.
///
/// Configuration is primarily validated with JSON Schema. But not all invalid configurations can
/// be prevented that way -- for example, encoding a constraint across different members of an
/// object can be difficult or impossible. The `Validate` trait supports running additional
/// validations on top of JSON Schema.
///
/// Most of the time, you should not use the `Validate` trait directly, but instead use
/// `#[config(validate)]` attributes. You can implement the `Validate` trait to use a custom type
/// as a configuration value.
///
/// # Lifecycle
///
/// Custom validation works on the actual Rust types. This means that any error that occurs
/// _before_ values are deserialized into Rust types short-circuits validation. Custom validation
/// can only enforce _additional constraints_ on top of the other steps.
///
/// One way this can manifest is that a user might get a single JSON schema validation error for a
/// field, but once they fix it, they might start getting an unrelated custom validation error for
/// a different field. This is similar to how fixing a function signature error in your Rust code
/// can make your code reach the "next stage" of compilation, so after fixing the one error you
/// might get dozens of new borrow-checker errors.
pub trait Validate {
    /// The default implementation of `validate` accepts all inputs.
    fn validate<'a>(&self, _errors: ErrorCollector<'a>) {}
}

// Implement a wildcard `Validate` for a bunch of types, accepting any input as valid.
impl Validate for bool {}
impl Validate for i8 {}
impl Validate for u8 {}
impl Validate for i16 {}
impl Validate for u16 {}
impl Validate for i32 {}
impl Validate for u32 {}
impl Validate for i64 {}
impl Validate for u64 {}
impl Validate for isize {}
impl Validate for usize {}
impl Validate for std::num::NonZeroI8 {}
impl Validate for std::num::NonZeroU8 {}
impl Validate for std::num::NonZeroI16 {}
impl Validate for std::num::NonZeroU16 {}
impl Validate for std::num::NonZeroI32 {}
impl Validate for std::num::NonZeroU32 {}
impl Validate for std::num::NonZeroI64 {}
impl Validate for std::num::NonZeroU64 {}
impl Validate for std::num::NonZeroIsize {}
impl Validate for std::num::NonZeroUsize {}
impl Validate for str {}
impl Validate for String {}
impl Validate for () {}

// Blanket impl for Redacted<T> - delegates validation to the inner type.
// This allows any type that implements Validate to be wrapped in Redacted
// without needing a separate impl.
impl<T: Validate, R> Validate for Redacted<T, R> {
    fn validate<'a>(&self, errors: ErrorCollector<'a>) {
        self.unredact().validate(errors);
    }
}

/// Validate a programmatically created value.
///
/// This is intended for use in testing custom validation rules, so you can
/// create a Rust value and just run the validator.
///
/// In the real world, configurations should be deserialized with [`crate::parse_yaml`], and
/// validation will be done by the apollo-configuration crate.
///
/// We also recommend using YAML configurations in tests as much as possible, to make sure
/// that you're testing configurations that your users can actually write.
pub fn validate<T: Validate>(value: T) -> Result<T, ValidationErrors> {
    let location_data = YamlLocationData::empty();

    let mut errors = vec![];
    value.validate(ErrorCollector::new(&location_data, &mut errors));

    if errors.is_empty() {
        Ok(value)
    } else {
        Err(ValidationErrors {
            source_code: Arc::from(""),
            errors,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::ErrorCollector;
    use super::Validate;
    use super::validate;

    #[test]
    fn programmatic_validation() {
        validate("".to_string()).expect("should pass for a simple value");
        validate(0).expect("should pass for a primitive value");

        #[derive(Debug)]
        struct Config {
            #[expect(unused)]
            a: usize,
            #[expect(unused)]
            b: usize,
        }
        impl Validate for Config {
            fn validate(&self, mut errors: ErrorCollector<'_>) {
                errors.nest("a").report_simple("always wrong");
                errors.nest("b").report_simple("never correct");
            }
        }

        let err = validate(Config { a: 0, b: 1 }).expect_err("should return errors");
        assert_eq!(err.errors.len(), 2);
    }
}