apollo-configuration 0.4.0

A typed, friendly configuration system for Apollo products.
Documentation

A typed, friendly configuration system for Apollo products.

The #[configuration] attribute

apollo-configuration's #[configuration] attribute provides:

  • Parsing of YAML files with friendly error reporting
  • JSON schema generation
  • Validation of user input according to that JSON schema
  • Defaulting of field values
  • Expansion of environment variables
use apollo_configuration::configuration;
use apollo_configuration::parse_yaml;

#[configuration]
struct CsrfConfig {
    #[config(default = true)]
    enabled: bool,
    #[config(default = default_required_headers())]
    required_headers: Vec<String>,
}

fn default_required_headers() -> Vec<String> {
    vec![
        "x-apollo-operation-name".to_string(),
        "apollo-require-preflight".to_string(),
    ]
}

JSON Schema Generation

Use [export_json_schema] to generate a JSON schema for a [Configuration] struct.

YAML Parsing

Use [parse_yaml] to parse and validate YAML text into a [Configuration] struct.

Validation

Configuration structs are validated using JSON Schema, and each field is validated according to its own validation rules. It's common for configuration structures to require "cross-cutting" validation: for example, disallowing two features that cannot be used together. To do things like this, you can add an additional custom validation function using the #[configuration(validate)] attribute.

use apollo_configuration::configuration;
use apollo_configuration::ErrorCollector;

#[configuration]
struct HomepageConfig {
    enabled: bool,
    graph_ref: Option<String>,
}

#[configuration]
struct ApolloSandboxConfig {
    enabled: bool,
}

#[configuration(validate = validate_config)]
struct Config {
    homepage: HomepageConfig,
    sandbox: ApolloSandboxConfig,
}

fn validate_config(config: &Config, mut errors: ErrorCollector<'_>) {
    if config.homepage.enabled && config.sandbox.enabled {
        errors.nest("sandbox")
            .report_simple("sandbox can only be enabled if homepage is disabled");
    }
}

The #[config] attribute

The #[config] attribute can be used on the structure's fields to change how fields are parsed, validated, and displayed.

Optional/Required fields

All fields are assumed to be optional by default. The type's [Default] impl is used when a field is absent during deserialization. If a field is required, mark it with #[config(required)]. If a configuration structure contains any required fields, it will not implement [Default].

If a field's type does not have a [Default] impl, you can provide a default value:

# use std::num::NonZeroUsize;
# use apollo_configuration::configuration;
#
#[configuration]
struct CacheConfig {
    #[config(default = NonZeroUsize::new(500).unwrap())]
    cache_size: NonZeroUsize,
}

Field validation

apollo-configuration supports schema-level validation and fully custom validation.

Schema-level validation works by using one of the below sub-attributes inside the #[config] attribute:

  • range(min = 0, max = 1024) - Restrict the value of an integer field to the given range.
  • length(min = 0, max = 256) - Restrict the length of a string or array field to the given range.
  • length(exact = 10) - Require a string or array field to have the given length exactly.
  • pattern(r"^\d+$") - Require a string field to match the given regular expression.

Schema-level validation is also reflected in the generated JSON schema.

Custom validation works by specifying a validation function in the #[config(validate)] attribute:

use apollo_configuration::configuration;
use apollo_configuration::ErrorCollector;

#[configuration]
struct Config {
    #[config(validate = validate_div_by_3)]
    magic_number: usize,
}

fn validate_div_by_3(magic_number: &usize, mut errors: ErrorCollector<'_>) {
    if !magic_number.is_multiple_of(3) {
        errors.report_simple("magic number must be divisible by 3");
    }
}

Custom validation is not reflected in the JSON schema. Generally, schema-level validation should be preferred. However, custom validation supports rich diagnostics using [ErrorCollector::report], so it can still be a good choice if schema-level validation gives poor errors.

Custom validation can be disabled using the #[config(skip_validate)] attribute. This can be necessary when using external types that do not implement Validate:

use apollo_configuration::configuration;

#[configuration]
struct Config {
    #[config(skip_validate, default = tinystr::TinyAsciiStr::try_from_utf8(b"unnamed").unwrap())]
    #[schemars(with = "String")]
    name: tinystr::TinyAsciiStr<16>,
}

Expansion

Your users can write ${env.VAR_NAME} syntax inside YAML values to insert the value of an environment variable into the configuration. This can be used to include more dynamic values or secrets without having to write them into the configuration file.

It is an error to reference an environment variable that is not defined. The bash-style defaulting syntax can be used to handle optional environment variables: ${env.VAR_NAME:-default}.

If your configuration structure contains secrets, API keys, passwords, or sensitive URLs, remember to use a redaction helper such as apollo-redaction.

Types for use as configuration values

Primitive Rust types and several ecosystem types are supported as configuration values. The [types] module provides wrappers for common external types that can be used in configuration.

Available newtypes:

  • [types::Duration] - human-readable format (30s, 5m, 1h30m, 100ms)
  • [types::IpAddr], [types::Ipv4Addr], [types::Ipv6Addr]
  • [types::SocketAddr], [types::SocketAddrV4], [types::SocketAddrV6]
  • [types::Url]
  • [types::Method], [types::Uri], [types::HeaderName], [types::HeaderValue]
  • [types::MetadataKey], [types::MetadataValue] - gRPC metadata types
  • [types::Regex]

To enable these types add the appropriate feature to the apollo-configuration dependency: duration, grpc, http, regex, url

All newtypes support wrapping with [apollo_redaction::Redacted] for sensitive values like API keys or passwords:

use apollo_configuration::configuration;
use apollo_redaction::Redacted;

#[configuration]
struct ApiConfig {
    #[config(required)]
    api_key: Redacted<String>,
}

let config: ApiConfig = apollo_configuration::parse_yaml(
    "api_key: secret-key-123",
    &Default::default(),
).unwrap();

// The value is accessible but protected from accidental logging
assert_eq!(config.api_key.unredact(), "secret-key-123");
assert_eq!(config.api_key.to_string(), "[REDACTED]");

See the apollo-redaction crate for details.

Other types

To use a type as a configuration value, it must implement these traits:

  • [Debug]
  • [schemars::JsonSchema]
  • [serde::Deserialize]

Where it makes sense, types used in configuration values should also implement [Clone], [Default], and [serde::Serialize].

Enum configuration

Enums can use the #[configuration] attribute. This is useful for configuration options where the user selects between different modes or backends.

use apollo_configuration::configuration;

#[configuration]
enum Endpoint {
    Http { url: String, timeout: u64 },
    Grpc { host: String, port: u16 },
}

Default variant

Mark a variant with #[config(default)] to make the enum implement [Default]. When parsing from YAML, omitted enum fields will use this default:

use apollo_configuration::configuration;
use apollo_configuration::parse_yaml;

#[configuration]
#[derive(PartialEq, Eq)]
enum LogLevel {
    Debug,
    #[config(default)]
    Info,
    Warn,
    Error,
}

#[configuration]
#[derive(PartialEq, Eq)]
struct Config {
    log_level: LogLevel,
}

let config: Config = parse_yaml(r#"log_level: debug"#, &Default::default()).unwrap();
assert_eq!(config.log_level, LogLevel::Debug);

let config: Config = parse_yaml(r#"{}"#, &Default::default()).unwrap();
assert_eq!(config.log_level, LogLevel::Info);

Unit variant fields

Unit variant enums can be parsed directly from string values in YAML. The #[configuration] attribute applies #[serde(rename_all = "snake_case")] automatically:

use apollo_configuration::configuration;

#[configuration]
#[derive(PartialEq, Eq)]
enum EnabledMode {
    Measure,
    #[config(default)]
    Enforce,
}

#[configuration]
struct DemandControlConfig {
    enabled: bool,
    mode: EnabledMode,
}

let config: DemandControlConfig = apollo_configuration::parse_yaml(r#"
    enabled: true
    mode: measure
"#, &Default::default()).unwrap();
assert_eq!(config.mode, EnabledMode::Measure);

Struct variant fields

Struct variant fields support the same #[config(...)] attributes as struct fields:

use apollo_configuration::configuration;

#[configuration]
enum Endpoint {
    #[config(default)]
    Http {
        #[config(default = "http://localhost".to_string())]
        url: String,
        #[config(default = 30)]
        timeout: u64,
    },
    Grpc {
        #[config(required)]
        host: String,
        #[config(default = 9090)]
        port: u16,
    },
}

Tuple variant fields

Tuple variants wrap a single inner type. The #[config(...)] attributes apply to the inner type:

use apollo_configuration::configuration;

#[configuration]
struct HttpConfig {
    #[config(default = "http://localhost".to_string())]
    url: String,
}

#[configuration]
enum Endpoint {
    #[config(default)]
    Http(HttpConfig),
    Custom(String),
}